From 7d37cc9401c0669ade0acd7588fa5a954998d20e Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Sat, 24 May 2025 15:39:10 +0200 Subject: [PATCH 01/45] Add configs --- .../core/config/BotCommandsConfigurations.kt | 3 ++ .../botcommands/api/core/config/BConfig.kt | 17 +++++++ .../api/core/config/BHotReloadConfig.kt | 44 +++++++++++++++++++ .../core/reload/ExperimentalHotReloadApi.kt | 17 +++++++ .../core/service/BCBotCommandsBootstrap.kt | 1 + 5 files changed, 82 insertions(+) create mode 100644 src/main/kotlin/io/github/freya022/botcommands/api/core/config/BHotReloadConfig.kt create mode 100644 src/main/kotlin/io/github/freya022/botcommands/api/core/reload/ExperimentalHotReloadApi.kt diff --git a/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/BotCommandsConfigurations.kt b/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/BotCommandsConfigurations.kt index e1ccf25cd..3488634f1 100644 --- a/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/BotCommandsConfigurations.kt +++ b/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/BotCommandsConfigurations.kt @@ -26,6 +26,8 @@ internal class BotCommandsCoreConfiguration( override val ignoredEventIntents: Set> = emptySet(), override val ignoreRestRateLimiter: Boolean = false, ) : BConfig { + + override val beforeStart: Nothing get() = unusable() override val classGraphProcessors: Nothing get() = unusable() override val serviceConfig: Nothing get() = unusable() override val databaseConfig: Nothing get() = unusable() @@ -36,6 +38,7 @@ internal class BotCommandsCoreConfiguration( override val modalsConfig: Nothing get() = unusable() override val componentsConfig: Nothing get() = unusable() override val coroutineScopesConfig: Nothing get() = unusable() + override val hotReloadConfig: Nothing get() = unusable() } internal fun BConfigBuilder.applyConfig(configuration: BotCommandsCoreConfiguration) = apply { diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BConfig.kt b/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BConfig.kt index 747c69665..a707d411a 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BConfig.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BConfig.kt @@ -4,6 +4,7 @@ import io.github.freya022.botcommands.api.ReceiverConsumer import io.github.freya022.botcommands.api.commands.text.annotations.Hidden import io.github.freya022.botcommands.api.core.BotOwners import io.github.freya022.botcommands.api.core.annotations.BEventListener +import io.github.freya022.botcommands.api.core.reload.ExperimentalHotReloadApi import io.github.freya022.botcommands.api.core.requests.PriorityGlobalRestRateLimiter import io.github.freya022.botcommands.api.core.service.ClassGraphProcessor import io.github.freya022.botcommands.api.core.service.annotations.InjectedService @@ -107,6 +108,9 @@ interface BConfig { val classGraphProcessors: List + // TODO is this really necessary? + val beforeStart: (() -> Unit)? + val serviceConfig: BServiceConfig val databaseConfig: BDatabaseConfig val localizationConfig: BLocalizationConfig @@ -116,6 +120,7 @@ interface BConfig { val modalsConfig: BModalsConfig val componentsConfig: BComponentsConfig val coroutineScopesConfig: BCoroutineScopesConfig + val hotReloadConfig: BHotReloadConfig } @ConfigDSL @@ -138,6 +143,8 @@ class BConfigBuilder : BConfig { override val classGraphProcessors: MutableList = arrayListOf() + override var beforeStart: (() -> Unit)? = null + override val serviceConfig = BServiceConfigBuilder() override val databaseConfig = BDatabaseConfigBuilder() override val localizationConfig = BLocalizationConfigBuilder() @@ -147,6 +154,8 @@ class BConfigBuilder : BConfig { override val modalsConfig = BModalsConfigBuilder() override val componentsConfig = BComponentsConfigBuilder() override val coroutineScopesConfig = BCoroutineScopesConfigBuilder() + @ExperimentalHotReloadApi + override val hotReloadConfig = BHotReloadConfigBuilder() /** * Predefined user IDs of the bot owners, allowing bypassing cooldowns, user permission checks, @@ -272,6 +281,11 @@ class BConfigBuilder : BConfig { componentsConfig.apply(block) } + @ExperimentalHotReloadApi + fun hotReload(block: ReceiverConsumer) { + hotReloadConfig.apply(block) + } + fun build(): BConfig { val logger = KotlinLogging.loggerOf() if (disableExceptionsInDMs) @@ -287,6 +301,7 @@ class BConfigBuilder : BConfig { override val ignoredEventIntents = this@BConfigBuilder.ignoredEventIntents.toImmutableSet() override val ignoreRestRateLimiter = this@BConfigBuilder.ignoreRestRateLimiter override val classGraphProcessors = this@BConfigBuilder.classGraphProcessors.toImmutableList() + override val beforeStart = this@BConfigBuilder.beforeStart override val serviceConfig = this@BConfigBuilder.serviceConfig.build() override val databaseConfig = this@BConfigBuilder.databaseConfig.build() override val localizationConfig = this@BConfigBuilder.localizationConfig.build() @@ -296,6 +311,8 @@ class BConfigBuilder : BConfig { override val modalsConfig = this@BConfigBuilder.modalsConfig.build() override val componentsConfig = this@BConfigBuilder.componentsConfig.build() override val coroutineScopesConfig = this@BConfigBuilder.coroutineScopesConfig.build() + @ExperimentalHotReloadApi + override val hotReloadConfig = this@BConfigBuilder.hotReloadConfig.build() } } } diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BHotReloadConfig.kt b/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BHotReloadConfig.kt new file mode 100644 index 000000000..cc6b8f8df --- /dev/null +++ b/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BHotReloadConfig.kt @@ -0,0 +1,44 @@ +package io.github.freya022.botcommands.api.core.config + +import io.github.freya022.botcommands.api.core.reload.ExperimentalHotReloadApi +import io.github.freya022.botcommands.api.core.service.annotations.InjectedService +import io.github.freya022.botcommands.api.core.utils.toImmutableList +import io.github.freya022.botcommands.internal.core.config.ConfigDSL +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +@InjectedService +interface BHotReloadConfig { + + val enable: Boolean + + val args: List + + // TODO java duration + val pollInterval: Duration + + // TODO java duration + val restartDelay: Duration +} + +@ExperimentalHotReloadApi +@ConfigDSL +class BHotReloadConfigBuilder : BHotReloadConfig { + + @set:JvmName("enable") + override var enable: Boolean = false + + override var args: List = emptyList() + + override val pollInterval: Duration = 900.milliseconds + + override val restartDelay: Duration = 1.seconds + + internal fun build() = object : BHotReloadConfig { + override val enable = this@BHotReloadConfigBuilder.enable + override val args = this@BHotReloadConfigBuilder.args.toImmutableList() + override val pollInterval = this@BHotReloadConfigBuilder.pollInterval + override val restartDelay = this@BHotReloadConfigBuilder.restartDelay + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/core/reload/ExperimentalHotReloadApi.kt b/src/main/kotlin/io/github/freya022/botcommands/api/core/reload/ExperimentalHotReloadApi.kt new file mode 100644 index 000000000..d717084dd --- /dev/null +++ b/src/main/kotlin/io/github/freya022/botcommands/api/core/reload/ExperimentalHotReloadApi.kt @@ -0,0 +1,17 @@ +package io.github.freya022.botcommands.api.core.reload + +/** + * Opt-in marker annotation for the hot reloading feature. + * + * This feature provides no guarantee and its API may change (including removals) at any time. + * + * Please create an issue if you encounter a problem, including if it needs adaptations for your use case. + */ +@RequiresOptIn( + message = "This feature is experimental, please see the documentation of this opt-in annotation (@ExperimentalHotReloadApi) for more details.", + level = RequiresOptIn.Level.ERROR +) +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY, AnnotationTarget.PROPERTY_SETTER) +@Retention(AnnotationRetention.BINARY) +@MustBeDocumented +annotation class ExperimentalHotReloadApi diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCBotCommandsBootstrap.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCBotCommandsBootstrap.kt index c9e1f9ff5..9c4d94a1c 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCBotCommandsBootstrap.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCBotCommandsBootstrap.kt @@ -47,6 +47,7 @@ internal class BCBotCommandsBootstrap internal constructor( serviceContainer.putServiceAs(config.componentsConfig) serviceContainer.putServiceAs(config.coroutineScopesConfig) serviceContainer.putServiceAs(config.textConfig) + serviceContainer.putServiceAs(config.hotReloadConfig) serviceContainer.loadServices() } From 0f00566eadf32b48036d65d7b2ce17c91ee4b54e Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Sat, 24 May 2025 15:42:00 +0200 Subject: [PATCH 02/45] Start adding hot reloading --- .../botcommands/api/core/BotCommands.kt | 8 ++ .../core/reload/ExpectedReloadException.kt | 3 + .../core/reload/HotReloadClassLoader.kt | 113 ++++++++++++++++++ .../internal/core/reload/RestartScheduler.kt | 25 ++++ .../internal/core/reload/Restarter.kt | 86 +++++++++++++ .../internal/core/reload/SourceDirectories.kt | 38 ++++++ .../internal/core/reload/SourceDirectory.kt | 104 ++++++++++++++++ .../internal/core/reload/SourceFile.kt | 12 ++ .../internal/core/reload/SourceFiles.kt | 16 +++ .../freya022/botcommands/reload/test/Main.kt | 63 ++++++++++ 10 files changed, 468 insertions(+) create mode 100644 src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/ExpectedReloadException.kt create mode 100644 src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/HotReloadClassLoader.kt create mode 100644 src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/RestartScheduler.kt create mode 100644 src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/Restarter.kt create mode 100644 src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectories.kt create mode 100644 src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectory.kt create mode 100644 src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceFile.kt create mode 100644 src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceFiles.kt create mode 100644 src/test/kotlin/io/github/freya022/botcommands/reload/test/Main.kt diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/core/BotCommands.kt b/src/main/kotlin/io/github/freya022/botcommands/api/core/BotCommands.kt index 96955e6c4..aedc961fd 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/api/core/BotCommands.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/api/core/BotCommands.kt @@ -7,6 +7,7 @@ import io.github.freya022.botcommands.api.core.config.BConfigBuilder import io.github.freya022.botcommands.api.core.service.annotations.BService import io.github.freya022.botcommands.api.core.service.annotations.InterfacedService import io.github.freya022.botcommands.api.core.service.getService +import io.github.freya022.botcommands.internal.core.reload.Restarter import io.github.freya022.botcommands.internal.core.service.BCBotCommandsBootstrap import io.github.oshai.kotlinlogging.KotlinLogging import kotlin.time.DurationUnit @@ -60,6 +61,13 @@ object BotCommands { } private fun build(config: BConfig): BContext { + val hotReloadConfig = config.hotReloadConfig + if (Restarter.shouldRestart(hotReloadConfig)) { + Restarter(hotReloadConfig).restart() + } + + config.beforeStart?.invoke() + val (context, duration) = measureTimedValue { val bootstrap = BCBotCommandsBootstrap(config) bootstrap.injectAndLoadServices() diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/ExpectedReloadException.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/ExpectedReloadException.kt new file mode 100644 index 000000000..a6cd092c1 --- /dev/null +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/ExpectedReloadException.kt @@ -0,0 +1,3 @@ +package io.github.freya022.botcommands.internal.core.reload + +internal class ExpectedReloadException : RuntimeException("Dummy exception to stop the execution of the first main thread") \ No newline at end of file diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/HotReloadClassLoader.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/HotReloadClassLoader.kt new file mode 100644 index 000000000..01e9d02a8 --- /dev/null +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/HotReloadClassLoader.kt @@ -0,0 +1,113 @@ +package io.github.freya022.botcommands.internal.core.reload + +import java.io.InputStream +import java.net.URL +import java.net.URLClassLoader +import java.net.URLConnection +import java.net.URLStreamHandler +import java.util.* + +internal class HotReloadClassLoader( + urls: List, + parent: ClassLoader, + private val sourceDirectories: SourceDirectories, +) : URLClassLoader(urls.toTypedArray(), parent) { + + override fun getResources(name: String): Enumeration { + val resources = parent.getResources(name) + val updatedFile = sourceDirectories.getFile(name) + + if (updatedFile != null) { + if (resources.hasMoreElements()) { + resources.nextElement() + } + if (updatedFile is SourceFile) { + return MergedEnumeration(createFileUrl(name, updatedFile), resources) + } + } + + return resources + } + + override fun getResource(name: String): URL? { + val updatedFile = sourceDirectories.getFile(name) + if (updatedFile is DeletedSourceFile) { + return null + } + + return findResource(name) ?: super.getResource(name) + } + + override fun findResource(name: String): URL? { + val updatedFile = sourceDirectories.getFile(name) + ?: return super.findResource(name) + return (updatedFile as? SourceFile)?.let { createFileUrl(name, it) } + } + + override fun loadClass(name: String, resolve: Boolean): Class<*> { + val path = "${name.replace('.', '/')}.class" + val updatedFile = sourceDirectories.getFile(path) + if (updatedFile is DeletedSourceFile) + throw ClassNotFoundException(name) + + return synchronized(getClassLoadingLock(name)) { + val loadedClass = findLoadedClass(name) ?: try { + findClass(name) + } catch (_: ClassNotFoundException) { + Class.forName(name, false, parent) + } + if (resolve) resolveClass(loadedClass) + loadedClass + } + } + + override fun findClass(name: String): Class<*> { + val path = "${name.replace('.', '/')}.class" + val updatedFile = sourceDirectories.getFile(path) + ?: return super.findClass(name) + if (updatedFile is DeletedSourceFile) + throw ClassNotFoundException(name) + + updatedFile as SourceFile + return defineClass(name, updatedFile.bytes, 0, updatedFile.bytes.size) + } + + @Suppress("DEPRECATION") // We target Java 17 but JDK 20 deprecates the URL constructors + private fun createFileUrl(name: String, file: SourceFile): URL { + return URL("reloaded", null, -1, "/$name", ClasspathFileURLStreamHandler(file)) + } + + private class ClasspathFileURLStreamHandler( + private val file: SourceFile, + ) : URLStreamHandler() { + + override fun openConnection(u: URL): URLConnection = Connection(u) + + private inner class Connection(url: URL): URLConnection(url) { + + override fun connect() {} + + override fun getInputStream(): InputStream = file.bytes.inputStream() + + override fun getLastModified(): Long = file.lastModified.toEpochMilli() + + override fun getContentLengthLong(): Long = file.bytes.size.toLong() + } + } + + private class MergedEnumeration(private val first: E, private val rest: Enumeration) : Enumeration { + + private var hasConsumedFirst = false + + override fun hasMoreElements(): Boolean = !hasConsumedFirst || rest.hasMoreElements() + + override fun nextElement(): E? { + if (!hasConsumedFirst) { + hasConsumedFirst = true + return first + } else { + return rest.nextElement() + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/RestartScheduler.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/RestartScheduler.kt new file mode 100644 index 000000000..3525bec5a --- /dev/null +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/RestartScheduler.kt @@ -0,0 +1,25 @@ +package io.github.freya022.botcommands.internal.core.reload + +import io.github.freya022.botcommands.api.core.config.BHotReloadConfig +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit + +internal class RestartScheduler( + private val hotReloadConfig: BHotReloadConfig, + private val restarter: Restarter, +) { + + private val scheduler = Executors.newSingleThreadScheduledExecutor() + private lateinit var scheduledRestart: ScheduledFuture<*> + + internal fun onClasspathChange(sourceDirectories: SourceDirectories) { + if (::scheduledRestart.isInitialized) { + scheduledRestart.cancel(false) + } + scheduledRestart = scheduler.schedule({ + println("Restart!") +// restarter.restart(sourceDirectories) + }, hotReloadConfig.restartDelay.inWholeMilliseconds, TimeUnit.MILLISECONDS) + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/Restarter.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/Restarter.kt new file mode 100644 index 000000000..451296115 --- /dev/null +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/Restarter.kt @@ -0,0 +1,86 @@ +package io.github.freya022.botcommands.internal.core.reload + +import io.github.freya022.botcommands.api.core.config.BHotReloadConfig +import io.github.freya022.botcommands.internal.utils.stackWalker +import java.io.File +import java.lang.management.ManagementFactory +import java.net.URL +import kotlin.concurrent.thread +import kotlin.io.path.isDirectory +import kotlin.io.path.toPath + +internal class Restarter( + private val hotReloadConfig: BHotReloadConfig +) { + + private val mainMethodClassName: String + private val classpathUrls: List + private val baseClassLoader: ClassLoader + + private val restartScheduler = RestartScheduler(hotReloadConfig, this) + + init { + check(Thread.currentThread().name == "main") + + val mainMethodFrame = stackWalker.walk { stream -> stream.filter { it.methodName == "main" }.toList().last() } + mainMethodClassName = mainMethodFrame.declaringClass.name + + classpathUrls = ManagementFactory.getRuntimeMXBean().classPath + .split(File.pathSeparator) + .map { File(it).toURI().toURL() } + baseClassLoader = Thread.currentThread().contextClassLoader!! + } + + internal fun restart(): Nothing { + val sourceDirectories = classpathUrls + .map { it.toURI().toPath() } + .filter { it.isDirectory() } + .let { SourceDirectories(hotReloadConfig, it, restartScheduler::onClasspathChange) } + restart(sourceDirectories) + } + + internal fun restart(sourceDirectories: SourceDirectories): Nothing { + val classLoader = HotReloadClassLoader( + urls = classpathUrls, + parent = baseClassLoader, + sourceDirectories, + ) + thread(name = RESTARTED_THREAD_NAME, contextClassLoader = classLoader) { + val mainClass = classLoader.loadClass(mainMethodClassName) + val mainMethod = mainClass.getDeclaredMethod("main", Array::class.java) + mainMethod.isAccessible = true + mainMethod.invoke(null, hotReloadConfig.args.toTypedArray()) + } + + exitThread() + } + + private fun exitThread(): Nothing { + val currentThread = Thread.currentThread() + currentThread.uncaughtExceptionHandler = ExpectedReloadExceptionHandler(currentThread.uncaughtExceptionHandler) + throw ExpectedReloadException() + } + + private class ExpectedReloadExceptionHandler(private val delegate: Thread.UncaughtExceptionHandler?) : Thread.UncaughtExceptionHandler { + + override fun uncaughtException(t: Thread, e: Throwable) { + if (e is ExpectedReloadException) { + return + } + + if (delegate != null) { + delegate.uncaughtException(t, e) + } else { + e.printStackTrace() + } + } + } + + internal companion object { + private const val RESTARTED_THREAD_NAME = "restartedMain" + + internal fun shouldRestart(config: BHotReloadConfig): Boolean { + return config.enable && Thread.currentThread().name != RESTARTED_THREAD_NAME + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectories.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectories.kt new file mode 100644 index 000000000..669e50239 --- /dev/null +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectories.kt @@ -0,0 +1,38 @@ +package io.github.freya022.botcommands.internal.core.reload + +import io.github.freya022.botcommands.api.core.config.BHotReloadConfig +import java.nio.file.Path + +internal class SourceDirectories internal constructor() { + private val directories: MutableMap = hashMapOf() + + internal fun getFile(path: String): ISourceFile? { + return directories.firstNotNullOfOrNull { it.value.files[path] } + } + + internal fun setSource(source: SourceDirectory) { + directories[source.directory] = source + } + + internal fun replaceSource(key: Path, supplier: () -> SourceDirectory) { + check(key in directories) + + directories[key] = supplier() + } +} + +internal fun SourceDirectories(hotReloadConfig: BHotReloadConfig, directories: List, onClasspathChange: (SourceDirectories) -> Unit): SourceDirectories { + val sourceDirectories = SourceDirectories() + + fun onSourceDirectoryUpdate(directory: Path, sourceFiles: SourceFiles) { + val newSourceDirectory = SourceDirectory(hotReloadConfig, directory, sourceFiles) { onSourceDirectoryUpdate(directory, it) } + sourceDirectories.replaceSource(directory) { newSourceDirectory } + onClasspathChange(sourceDirectories) + } + + directories.forEach { directory -> + sourceDirectories.setSource(SourceDirectory(hotReloadConfig, directory) { onSourceDirectoryUpdate(directory, it) }) + } + + return sourceDirectories +} \ No newline at end of file diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectory.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectory.kt new file mode 100644 index 000000000..f4b34d18e --- /dev/null +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectory.kt @@ -0,0 +1,104 @@ +package io.github.freya022.botcommands.internal.core.reload + +import io.github.freya022.botcommands.api.core.config.BHotReloadConfig +import io.github.oshai.kotlinlogging.KotlinLogging +import java.io.IOException +import java.nio.file.FileVisitResult +import java.nio.file.FileVisitor +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.BasicFileAttributes +import java.nio.file.attribute.FileTime +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.io.path.* + +private val logger = KotlinLogging.logger { } + +@OptIn(ExperimentalPathApi::class) +internal class SourceDirectory internal constructor( + hotReloadConfig: BHotReloadConfig, + val directory: Path, + val files: SourceFiles, + private val onClasspathChange: (SourceFiles) -> Unit, +) { + private val scheduler = Executors.newSingleThreadScheduledExecutor { r -> + Thread(r, "Classpath watcher of ${directory.fileName}") + } + + init { + require(directory.isDirectory()) + + logger.debug { "Listening to ${directory.absolutePathString()}" } + + scheduler.scheduleWithFixedDelay({ + val snapshot = takeLightSnapshot() + + // Exclude deleted files so they don't count as being deleted again + val deletedPaths = files.withoutDeletes().keys - snapshot.keys + if (deletedPaths.isNotEmpty()) { + logger.info { "Deleted files in ${directory.absolutePathString()}: $deletedPaths" } + onClasspathChange(deletedPaths.associateWith { DeletedSourceFile } + directory.takeSnapshot()) + return@scheduleWithFixedDelay scheduler.shutdown() + } + + // Exclude deleted files so they count as being added back + val addedPaths = snapshot.keys - files.withoutDeletes().keys + if (addedPaths.isNotEmpty()) { + logger.info { "Added files in ${directory.absolutePathString()}: $addedPaths" } + onClasspathChange(files + directory.takeSnapshot()) + return@scheduleWithFixedDelay scheduler.shutdown() + } + + val modifiedFiles = snapshot.keys.filter { key -> snapshot[key]?.toInstant() != (files[key] as? SourceFile)?.lastModified } + if (modifiedFiles.isNotEmpty()) { + logger.info { "Timestamp changed in ${directory.absolutePathString()}: $modifiedFiles" } + onClasspathChange(files + directory.takeSnapshot()) + return@scheduleWithFixedDelay scheduler.shutdown() + } + }, hotReloadConfig.pollInterval.inWholeMilliseconds, hotReloadConfig.pollInterval.inWholeMilliseconds, TimeUnit.MILLISECONDS) + } + + private fun takeLightSnapshot(): Map = directory.walkFiles().associate { (it, attrs) -> + it.relativeTo(directory).pathString to attrs.lastModifiedTime() + } +} + +internal fun SourceDirectory(hotReloadConfig: BHotReloadConfig, directory: Path, onClasspathChange: (SourceFiles) -> Unit): SourceDirectory { + return SourceDirectory(hotReloadConfig, directory, directory.takeSnapshot(), onClasspathChange) +} + +private fun Path.takeSnapshot(): SourceFiles = walkFiles().associate { (it, attrs) -> + it.relativeTo(this).pathString to SourceFile(attrs.lastModifiedTime().toInstant(), it.readBytes()) +}.let(::SourceFiles) + +// Optimization of Path#walk, cuts CPU usage by 4 +// mostly by eliminating duplicate calls to file attributes +private fun Path.walkFiles(): List> { + return buildList { + Files.walkFileTree(this@walkFiles, object : FileVisitor { + override fun preVisitDirectory( + dir: Path, + attrs: BasicFileAttributes + ): FileVisitResult = FileVisitResult.CONTINUE + + override fun visitFile( + file: Path, + attrs: BasicFileAttributes + ): FileVisitResult { + add(file to attrs) + return FileVisitResult.CONTINUE + } + + override fun visitFileFailed( + file: Path, + exc: IOException + ): FileVisitResult = FileVisitResult.CONTINUE + + override fun postVisitDirectory( + dir: Path, + exc: IOException? + ): FileVisitResult = FileVisitResult.CONTINUE + }) + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceFile.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceFile.kt new file mode 100644 index 000000000..7bb5be8bf --- /dev/null +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceFile.kt @@ -0,0 +1,12 @@ +package io.github.freya022.botcommands.internal.core.reload + +import java.time.Instant + +internal sealed interface ISourceFile + +internal class SourceFile( + val lastModified: Instant, + val bytes: ByteArray, +) : ISourceFile + +internal object DeletedSourceFile : ISourceFile \ No newline at end of file diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceFiles.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceFiles.kt new file mode 100644 index 000000000..9f3310b99 --- /dev/null +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceFiles.kt @@ -0,0 +1,16 @@ +package io.github.freya022.botcommands.internal.core.reload + +internal class SourceFiles internal constructor( + internal val files: Map, +) { + + val keys: Set get() = files.keys + + internal operator fun get(path: String): ISourceFile? = files[path] + + internal fun withoutDeletes(): SourceFiles = SourceFiles(files.filterValues { it !is DeletedSourceFile }) + + internal operator fun plus(other: SourceFiles): SourceFiles = SourceFiles(files + other.files) +} + +internal operator fun Map.plus(other: SourceFiles): SourceFiles = SourceFiles(this + other.files) \ No newline at end of file diff --git a/src/test/kotlin/io/github/freya022/botcommands/reload/test/Main.kt b/src/test/kotlin/io/github/freya022/botcommands/reload/test/Main.kt new file mode 100644 index 000000000..714425cba --- /dev/null +++ b/src/test/kotlin/io/github/freya022/botcommands/reload/test/Main.kt @@ -0,0 +1,63 @@ +package io.github.freya022.botcommands.reload.test + +import ch.qos.logback.classic.ClassicConstants +import dev.reformator.stacktracedecoroutinator.jvm.DecoroutinatorJvmApi +import io.github.freya022.botcommands.api.core.BotCommands +import io.github.freya022.botcommands.api.core.config.DevConfig +import io.github.freya022.botcommands.api.core.reload.ExperimentalHotReloadApi +import io.github.freya022.botcommands.test.config.Environment +import io.github.oshai.kotlinlogging.KotlinLogging +import java.lang.management.ManagementFactory +import kotlin.io.path.absolutePathString + +private val logger by lazy { KotlinLogging.logger { } } + +fun main(args: Array) { + System.setProperty(ClassicConstants.CONFIG_FILE_PROPERTY, Environment.logbackConfigPath.absolutePathString()) + logger.info { "Loading logback configuration at ${Environment.logbackConfigPath.absolutePathString()}" } + + // I use hotswap agent to update my code without restarting the bot + // Of course this only supports modifying existing code + // Refer to https://github.com/HotswapProjects/HotswapAgent#readme on how to use hotswap + + // stacktrace-decoroutinator has issues when reloading with hotswap agent + if ("-XX:+AllowEnhancedClassRedefinition" in ManagementFactory.getRuntimeMXBean().inputArguments) { + logger.info { "Skipping stacktrace-decoroutinator as enhanced hotswap is active" } + } else if ("--no-decoroutinator" in args) { + logger.info { "Skipping stacktrace-decoroutinator as --no-decoroutinator is specified" } + } else { + DecoroutinatorJvmApi.install() + } + + BotCommands.create { + disableExceptionsInDMs = true + + addSearchPath("io.github.freya022.botcommands.reload.test") + + beforeStart = { Thread.sleep(Long.MAX_VALUE) } + + textCommands { + enable = false + } + + applicationCommands { + enable = false + + databaseCache { + @OptIn(DevConfig::class) + checkOnline = true + } + } + + modals { + enable = false + } + + @OptIn(ExperimentalHotReloadApi::class) + hotReload { + enable = true + + this.args = args.toList() + } + } +} \ No newline at end of file From d7063343da0799a6dd2907e032e8d96d87054015 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Sat, 24 May 2025 16:51:04 +0200 Subject: [PATCH 03/45] Listen to file changes using the NIO WatchService --- .../api/core/config/BHotReloadConfig.kt | 7 - .../internal/core/reload/RestartScheduler.kt | 21 ++- .../internal/core/reload/Restarter.kt | 11 +- .../internal/core/reload/SourceDirectories.kt | 24 ++-- .../core/reload/SourceDirectoriesListener.kt | 5 + .../internal/core/reload/SourceDirectory.kt | 120 ++++++++++++------ .../core/reload/SourceDirectoryListener.kt | 5 + 7 files changed, 126 insertions(+), 67 deletions(-) create mode 100644 src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectoriesListener.kt create mode 100644 src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectoryListener.kt diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BHotReloadConfig.kt b/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BHotReloadConfig.kt index cc6b8f8df..97209ff31 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BHotReloadConfig.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BHotReloadConfig.kt @@ -5,7 +5,6 @@ import io.github.freya022.botcommands.api.core.service.annotations.InjectedServi import io.github.freya022.botcommands.api.core.utils.toImmutableList import io.github.freya022.botcommands.internal.core.config.ConfigDSL import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @InjectedService @@ -15,9 +14,6 @@ interface BHotReloadConfig { val args: List - // TODO java duration - val pollInterval: Duration - // TODO java duration val restartDelay: Duration } @@ -31,14 +27,11 @@ class BHotReloadConfigBuilder : BHotReloadConfig { override var args: List = emptyList() - override val pollInterval: Duration = 900.milliseconds - override val restartDelay: Duration = 1.seconds internal fun build() = object : BHotReloadConfig { override val enable = this@BHotReloadConfigBuilder.enable override val args = this@BHotReloadConfigBuilder.args.toImmutableList() - override val pollInterval = this@BHotReloadConfigBuilder.pollInterval override val restartDelay = this@BHotReloadConfigBuilder.restartDelay } } \ No newline at end of file diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/RestartScheduler.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/RestartScheduler.kt index 3525bec5a..d022d398c 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/RestartScheduler.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/RestartScheduler.kt @@ -4,6 +4,8 @@ import io.github.freya022.botcommands.api.core.config.BHotReloadConfig import java.util.concurrent.Executors import java.util.concurrent.ScheduledFuture import java.util.concurrent.TimeUnit +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock internal class RestartScheduler( private val hotReloadConfig: BHotReloadConfig, @@ -13,11 +15,22 @@ internal class RestartScheduler( private val scheduler = Executors.newSingleThreadScheduledExecutor() private lateinit var scheduledRestart: ScheduledFuture<*> - internal fun onClasspathChange(sourceDirectories: SourceDirectories) { - if (::scheduledRestart.isInitialized) { - scheduledRestart.cancel(false) - } + private val commands: MutableList<() -> Unit> = arrayListOf() + private val lock = ReentrantLock() + + /** + * This method queues the provided [command] and executes them before restarting, + * allowing to only load binaries after everything has been compiled + */ + internal fun queueClasspathChange(command: () -> Unit): Unit = lock.withLock { + commands += command + if (::scheduledRestart.isInitialized) scheduledRestart.cancel(false) + scheduledRestart = scheduler.schedule({ + lock.withLock { + commands.forEach { it.invoke() } + commands.clear() + } println("Restart!") // restarter.restart(sourceDirectories) }, hotReloadConfig.restartDelay.inWholeMilliseconds, TimeUnit.MILLISECONDS) diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/Restarter.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/Restarter.kt index 451296115..b376147b1 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/Restarter.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/Restarter.kt @@ -17,6 +17,8 @@ internal class Restarter( private val classpathUrls: List private val baseClassLoader: ClassLoader + private val sourceDirectories: SourceDirectories + private val restartScheduler = RestartScheduler(hotReloadConfig, this) init { @@ -29,17 +31,14 @@ internal class Restarter( .split(File.pathSeparator) .map { File(it).toURI().toURL() } baseClassLoader = Thread.currentThread().contextClassLoader!! - } - internal fun restart(): Nothing { - val sourceDirectories = classpathUrls + sourceDirectories = classpathUrls .map { it.toURI().toPath() } .filter { it.isDirectory() } - .let { SourceDirectories(hotReloadConfig, it, restartScheduler::onClasspathChange) } - restart(sourceDirectories) + .let { SourceDirectories(it, listener = restartScheduler::queueClasspathChange) } } - internal fun restart(sourceDirectories: SourceDirectories): Nothing { + internal fun restart(): Nothing { val classLoader = HotReloadClassLoader( urls = classpathUrls, parent = baseClassLoader, diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectories.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectories.kt index 669e50239..e748d2fa6 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectories.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectories.kt @@ -1,6 +1,5 @@ package io.github.freya022.botcommands.internal.core.reload -import io.github.freya022.botcommands.api.core.config.BHotReloadConfig import java.nio.file.Path internal class SourceDirectories internal constructor() { @@ -14,24 +13,31 @@ internal class SourceDirectories internal constructor() { directories[source.directory] = source } - internal fun replaceSource(key: Path, supplier: () -> SourceDirectory) { + internal fun replaceSource(key: Path, directory: SourceDirectory) { check(key in directories) - directories[key] = supplier() + directories[key] = directory } } -internal fun SourceDirectories(hotReloadConfig: BHotReloadConfig, directories: List, onClasspathChange: (SourceDirectories) -> Unit): SourceDirectories { +internal fun SourceDirectories(directories: List, listener: SourceDirectoriesListener): SourceDirectories { val sourceDirectories = SourceDirectories() - fun onSourceDirectoryUpdate(directory: Path, sourceFiles: SourceFiles) { - val newSourceDirectory = SourceDirectory(hotReloadConfig, directory, sourceFiles) { onSourceDirectoryUpdate(directory, it) } - sourceDirectories.replaceSource(directory) { newSourceDirectory } - onClasspathChange(sourceDirectories) + fun onSourceDirectoryUpdate(directory: Path, sourceFilesFactory: () -> SourceFiles) { + // The command is called when restarting + // so we don't make snapshots before all changes went through + listener.onChange(command = { + val newSourceDirectory = SourceDirectory( + directory, + sourceFilesFactory(), + listener = { onSourceDirectoryUpdate(directory, it) } + ) + sourceDirectories.replaceSource(directory, newSourceDirectory) + }) } directories.forEach { directory -> - sourceDirectories.setSource(SourceDirectory(hotReloadConfig, directory) { onSourceDirectoryUpdate(directory, it) }) + sourceDirectories.setSource(SourceDirectory(directory, listener = { onSourceDirectoryUpdate(directory, it) })) } return sourceDirectories diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectoriesListener.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectoriesListener.kt new file mode 100644 index 000000000..75d7a7ac5 --- /dev/null +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectoriesListener.kt @@ -0,0 +1,5 @@ +package io.github.freya022.botcommands.internal.core.reload + +internal fun interface SourceDirectoriesListener { + fun onChange(command: () -> Unit) +} \ No newline at end of file diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectory.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectory.kt index f4b34d18e..71ded2003 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectory.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectory.kt @@ -1,71 +1,82 @@ package io.github.freya022.botcommands.internal.core.reload -import io.github.freya022.botcommands.api.core.config.BHotReloadConfig +import io.github.freya022.botcommands.internal.utils.throwInternal import io.github.oshai.kotlinlogging.KotlinLogging import java.io.IOException import java.nio.file.FileVisitResult import java.nio.file.FileVisitor import java.nio.file.Files import java.nio.file.Path +import java.nio.file.StandardWatchEventKinds.* import java.nio.file.attribute.BasicFileAttributes -import java.nio.file.attribute.FileTime -import java.util.concurrent.Executors -import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread import kotlin.io.path.* private val logger = KotlinLogging.logger { } @OptIn(ExperimentalPathApi::class) internal class SourceDirectory internal constructor( - hotReloadConfig: BHotReloadConfig, val directory: Path, val files: SourceFiles, - private val onClasspathChange: (SourceFiles) -> Unit, + private val listener: SourceDirectoryListener, ) { - private val scheduler = Executors.newSingleThreadScheduledExecutor { r -> - Thread(r, "Classpath watcher of ${directory.fileName}") - } init { require(directory.isDirectory()) logger.debug { "Listening to ${directory.absolutePathString()}" } - scheduler.scheduleWithFixedDelay({ - val snapshot = takeLightSnapshot() - - // Exclude deleted files so they don't count as being deleted again - val deletedPaths = files.withoutDeletes().keys - snapshot.keys - if (deletedPaths.isNotEmpty()) { - logger.info { "Deleted files in ${directory.absolutePathString()}: $deletedPaths" } - onClasspathChange(deletedPaths.associateWith { DeletedSourceFile } + directory.takeSnapshot()) - return@scheduleWithFixedDelay scheduler.shutdown() - } - - // Exclude deleted files so they count as being added back - val addedPaths = snapshot.keys - files.withoutDeletes().keys - if (addedPaths.isNotEmpty()) { - logger.info { "Added files in ${directory.absolutePathString()}: $addedPaths" } - onClasspathChange(files + directory.takeSnapshot()) - return@scheduleWithFixedDelay scheduler.shutdown() - } - - val modifiedFiles = snapshot.keys.filter { key -> snapshot[key]?.toInstant() != (files[key] as? SourceFile)?.lastModified } - if (modifiedFiles.isNotEmpty()) { - logger.info { "Timestamp changed in ${directory.absolutePathString()}: $modifiedFiles" } - onClasspathChange(files + directory.takeSnapshot()) - return@scheduleWithFixedDelay scheduler.shutdown() - } - }, hotReloadConfig.pollInterval.inWholeMilliseconds, hotReloadConfig.pollInterval.inWholeMilliseconds, TimeUnit.MILLISECONDS) - } - - private fun takeLightSnapshot(): Map = directory.walkFiles().associate { (it, attrs) -> - it.relativeTo(directory).pathString to attrs.lastModifiedTime() + val watchService = directory.fileSystem.newWatchService() + directory.walkDirectories { path, attributes -> + path.register(watchService, ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE) + } + + thread(name = "Classpath watcher of '${directory.fileName}'", isDaemon = true) { + watchService.take() // Wait for a change + watchService.close() + + listener.onChange(sourcesFilesFactory = { + val snapshot = directory.takeSnapshot() + + // Exclude deleted files so they don't count as being deleted again + val deletedPaths = files.withoutDeletes().keys - snapshot.keys + if (deletedPaths.isNotEmpty()) { + logger.info { "Deleted files in ${directory.absolutePathString()}: $deletedPaths" } + return@onChange deletedPaths.associateWith { DeletedSourceFile } + snapshot + } + + // Exclude deleted files so they count as being added back + val addedPaths = snapshot.keys - files.withoutDeletes().keys + if (addedPaths.isNotEmpty()) { + logger.info { "Added files in ${directory.absolutePathString()}: $addedPaths" } + return@onChange files + snapshot + } + + val modifiedFiles = snapshot.keys.filter { key -> + val actual = snapshot[key] ?: throwInternal("Key from map is missing a value somehow") + val expected = files[key] ?: throwInternal("Expected file is missing, should have been detected as deleted") + + // File was deleted (on the 2nd build for example) and got recreated (on the 3rd build for example) + if (expected is DeletedSourceFile) throwInternal("Expected file was registered as deleted, should have been detected as added") + expected as SourceFile + + actual as SourceFile // Assertion + + actual.lastModified != expected.lastModified + } + if (modifiedFiles.isNotEmpty()) { + logger.info { "Timestamp changed in ${directory.absolutePathString()}: $modifiedFiles" } + return@onChange files + snapshot + } + + throwInternal("Received a file system event but no changes were detected") + }) + } } } -internal fun SourceDirectory(hotReloadConfig: BHotReloadConfig, directory: Path, onClasspathChange: (SourceFiles) -> Unit): SourceDirectory { - return SourceDirectory(hotReloadConfig, directory, directory.takeSnapshot(), onClasspathChange) +internal fun SourceDirectory(directory: Path, listener: SourceDirectoryListener): SourceDirectory { + return SourceDirectory(directory, directory.takeSnapshot(), listener) } private fun Path.takeSnapshot(): SourceFiles = walkFiles().associate { (it, attrs) -> @@ -101,4 +112,31 @@ private fun Path.walkFiles(): List> { ): FileVisitResult = FileVisitResult.CONTINUE }) } +} + +private fun Path.walkDirectories(block: (Path, BasicFileAttributes) -> Unit) { + Files.walkFileTree(this@walkDirectories, object : FileVisitor { + override fun preVisitDirectory( + dir: Path, + attrs: BasicFileAttributes + ): FileVisitResult { + block(dir, attrs) + return FileVisitResult.CONTINUE + } + + override fun visitFile( + file: Path, + attrs: BasicFileAttributes + ): FileVisitResult = FileVisitResult.CONTINUE + + override fun visitFileFailed( + file: Path, + exc: IOException + ): FileVisitResult = FileVisitResult.CONTINUE + + override fun postVisitDirectory( + dir: Path, + exc: IOException? + ): FileVisitResult = FileVisitResult.CONTINUE + }) } \ No newline at end of file diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectoryListener.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectoryListener.kt new file mode 100644 index 000000000..3841654bd --- /dev/null +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectoryListener.kt @@ -0,0 +1,5 @@ +package io.github.freya022.botcommands.internal.core.reload + +internal fun interface SourceDirectoryListener { + fun onChange(sourcesFilesFactory: () -> SourceFiles) +} \ No newline at end of file From f0a2255c8a74c12f0363007d7c32246b96e53462 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Sat, 24 May 2025 16:53:12 +0200 Subject: [PATCH 04/45] Move path walkers to a util file --- .../internal/core/reload/SourceDirectory.kt | 65 +----------------- .../botcommands/internal/utils/NIO.kt | 66 +++++++++++++++++++ 2 files changed, 68 insertions(+), 63 deletions(-) create mode 100644 src/main/kotlin/io/github/freya022/botcommands/internal/utils/NIO.kt diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectory.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectory.kt index 71ded2003..a93244b5d 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectory.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectory.kt @@ -1,14 +1,11 @@ package io.github.freya022.botcommands.internal.core.reload import io.github.freya022.botcommands.internal.utils.throwInternal +import io.github.freya022.botcommands.internal.utils.walkDirectories +import io.github.freya022.botcommands.internal.utils.walkFiles import io.github.oshai.kotlinlogging.KotlinLogging -import java.io.IOException -import java.nio.file.FileVisitResult -import java.nio.file.FileVisitor -import java.nio.file.Files import java.nio.file.Path import java.nio.file.StandardWatchEventKinds.* -import java.nio.file.attribute.BasicFileAttributes import kotlin.concurrent.thread import kotlin.io.path.* @@ -82,61 +79,3 @@ internal fun SourceDirectory(directory: Path, listener: SourceDirectoryListener) private fun Path.takeSnapshot(): SourceFiles = walkFiles().associate { (it, attrs) -> it.relativeTo(this).pathString to SourceFile(attrs.lastModifiedTime().toInstant(), it.readBytes()) }.let(::SourceFiles) - -// Optimization of Path#walk, cuts CPU usage by 4 -// mostly by eliminating duplicate calls to file attributes -private fun Path.walkFiles(): List> { - return buildList { - Files.walkFileTree(this@walkFiles, object : FileVisitor { - override fun preVisitDirectory( - dir: Path, - attrs: BasicFileAttributes - ): FileVisitResult = FileVisitResult.CONTINUE - - override fun visitFile( - file: Path, - attrs: BasicFileAttributes - ): FileVisitResult { - add(file to attrs) - return FileVisitResult.CONTINUE - } - - override fun visitFileFailed( - file: Path, - exc: IOException - ): FileVisitResult = FileVisitResult.CONTINUE - - override fun postVisitDirectory( - dir: Path, - exc: IOException? - ): FileVisitResult = FileVisitResult.CONTINUE - }) - } -} - -private fun Path.walkDirectories(block: (Path, BasicFileAttributes) -> Unit) { - Files.walkFileTree(this@walkDirectories, object : FileVisitor { - override fun preVisitDirectory( - dir: Path, - attrs: BasicFileAttributes - ): FileVisitResult { - block(dir, attrs) - return FileVisitResult.CONTINUE - } - - override fun visitFile( - file: Path, - attrs: BasicFileAttributes - ): FileVisitResult = FileVisitResult.CONTINUE - - override fun visitFileFailed( - file: Path, - exc: IOException - ): FileVisitResult = FileVisitResult.CONTINUE - - override fun postVisitDirectory( - dir: Path, - exc: IOException? - ): FileVisitResult = FileVisitResult.CONTINUE - }) -} \ No newline at end of file diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/utils/NIO.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/utils/NIO.kt new file mode 100644 index 000000000..469c61680 --- /dev/null +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/utils/NIO.kt @@ -0,0 +1,66 @@ +package io.github.freya022.botcommands.internal.utils + +import java.io.IOException +import java.nio.file.FileVisitResult +import java.nio.file.FileVisitor +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.BasicFileAttributes + +// Optimization of Path#walk, cuts CPU usage by 4 +// mostly by eliminating duplicate calls to file attributes +fun Path.walkFiles(): List> { + return buildList { + Files.walkFileTree(this@walkFiles, object : FileVisitor { + override fun preVisitDirectory( + dir: Path, + attrs: BasicFileAttributes + ): FileVisitResult = FileVisitResult.CONTINUE + + override fun visitFile( + file: Path, + attrs: BasicFileAttributes + ): FileVisitResult { + add(file to attrs) + return FileVisitResult.CONTINUE + } + + override fun visitFileFailed( + file: Path, + exc: IOException + ): FileVisitResult = FileVisitResult.CONTINUE + + override fun postVisitDirectory( + dir: Path, + exc: IOException? + ): FileVisitResult = FileVisitResult.CONTINUE + }) + } +} + +fun Path.walkDirectories(block: (Path, BasicFileAttributes) -> Unit) { + Files.walkFileTree(this@walkDirectories, object : FileVisitor { + override fun preVisitDirectory( + dir: Path, + attrs: BasicFileAttributes + ): FileVisitResult { + block(dir, attrs) + return FileVisitResult.CONTINUE + } + + override fun visitFile( + file: Path, + attrs: BasicFileAttributes + ): FileVisitResult = FileVisitResult.CONTINUE + + override fun visitFileFailed( + file: Path, + exc: IOException + ): FileVisitResult = FileVisitResult.CONTINUE + + override fun postVisitDirectory( + dir: Path, + exc: IOException? + ): FileVisitResult = FileVisitResult.CONTINUE + }) +} \ No newline at end of file From 01b864317a870f82c7af6598eeab1654c18b1608 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Sun, 25 May 2025 16:44:41 +0200 Subject: [PATCH 05/45] Start adding restart listeners, refactor --- .../botcommands/api/core/BotCommands.kt | 2 +- .../core/reload/ExpectedReloadException.kt | 26 ++++++- .../internal/core/reload/RestartScheduler.kt | 3 +- .../internal/core/reload/Restarter.kt | 67 ++++++++++++------- .../internal/core/reload/RestarterListener.kt | 6 ++ 5 files changed, 77 insertions(+), 27 deletions(-) create mode 100644 src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/RestarterListener.kt diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/core/BotCommands.kt b/src/main/kotlin/io/github/freya022/botcommands/api/core/BotCommands.kt index aedc961fd..2731e5947 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/api/core/BotCommands.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/api/core/BotCommands.kt @@ -63,7 +63,7 @@ object BotCommands { private fun build(config: BConfig): BContext { val hotReloadConfig = config.hotReloadConfig if (Restarter.shouldRestart(hotReloadConfig)) { - Restarter(hotReloadConfig).restart() + Restarter.initialize(hotReloadConfig) } config.beforeStart?.invoke() diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/ExpectedReloadException.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/ExpectedReloadException.kt index a6cd092c1..65928b461 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/ExpectedReloadException.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/ExpectedReloadException.kt @@ -1,3 +1,27 @@ package io.github.freya022.botcommands.internal.core.reload -internal class ExpectedReloadException : RuntimeException("Dummy exception to stop the execution of the first main thread") \ No newline at end of file +internal class ExpectedReloadException : RuntimeException("Dummy exception to stop the execution of the first main thread") { + + companion object { + fun throwAndHandle(): Nothing { + val currentThread = Thread.currentThread() + currentThread.uncaughtExceptionHandler = ExpectedReloadExceptionHandler(currentThread.uncaughtExceptionHandler) + throw ExpectedReloadException() + } + } + + private class ExpectedReloadExceptionHandler(private val delegate: Thread.UncaughtExceptionHandler?) : Thread.UncaughtExceptionHandler { + + override fun uncaughtException(t: Thread, e: Throwable) { + if (e is ExpectedReloadException) { + return + } + + if (delegate != null) { + delegate.uncaughtException(t, e) + } else { + e.printStackTrace() + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/RestartScheduler.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/RestartScheduler.kt index d022d398c..27e77df5d 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/RestartScheduler.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/RestartScheduler.kt @@ -32,7 +32,8 @@ internal class RestartScheduler( commands.clear() } println("Restart!") -// restarter.restart(sourceDirectories) + restarter.restart() + scheduler.shutdown() }, hotReloadConfig.restartDelay.inWholeMilliseconds, TimeUnit.MILLISECONDS) } } \ No newline at end of file diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/Restarter.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/Restarter.kt index b376147b1..919eda5d9 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/Restarter.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/Restarter.kt @@ -2,14 +2,19 @@ package io.github.freya022.botcommands.internal.core.reload import io.github.freya022.botcommands.api.core.config.BHotReloadConfig import io.github.freya022.botcommands.internal.utils.stackWalker +import io.github.oshai.kotlinlogging.KotlinLogging import java.io.File import java.lang.management.ManagementFactory import java.net.URL +import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.thread +import kotlin.concurrent.withLock import kotlin.io.path.isDirectory import kotlin.io.path.toPath -internal class Restarter( +private val logger = KotlinLogging.logger { } + +internal class Restarter private constructor( private val hotReloadConfig: BHotReloadConfig ) { @@ -21,6 +26,8 @@ internal class Restarter( private val restartScheduler = RestartScheduler(hotReloadConfig, this) + private val listeners: MutableList = arrayListOf() + init { check(Thread.currentThread().name == "main") @@ -38,45 +45,57 @@ internal class Restarter( .let { SourceDirectories(it, listener = restartScheduler::queueClasspathChange) } } - internal fun restart(): Nothing { + internal fun addListener(listener: RestarterListener) { + listeners += listener + } + + private fun stop() { + listeners.forEach { it.beforeRestart() } + } + + private fun start() { val classLoader = HotReloadClassLoader( urls = classpathUrls, parent = baseClassLoader, sourceDirectories, ) thread(name = RESTARTED_THREAD_NAME, contextClassLoader = classLoader) { - val mainClass = classLoader.loadClass(mainMethodClassName) - val mainMethod = mainClass.getDeclaredMethod("main", Array::class.java) - mainMethod.isAccessible = true - mainMethod.invoke(null, hotReloadConfig.args.toTypedArray()) + try { + val mainClass = classLoader.loadClass(mainMethodClassName) + val mainMethod = mainClass.getDeclaredMethod("main", Array::class.java) + mainMethod.isAccessible = true + mainMethod.invoke(null, hotReloadConfig.args.toTypedArray()) + } catch (e: Throwable) { + logger.error(e) { "An error occurred while running the main class" } + } } + } - exitThread() + private fun initialize(): Nothing { + start() + ExpectedReloadException.throwAndHandle() } - private fun exitThread(): Nothing { - val currentThread = Thread.currentThread() - currentThread.uncaughtExceptionHandler = ExpectedReloadExceptionHandler(currentThread.uncaughtExceptionHandler) - throw ExpectedReloadException() + internal fun restart() { + stop() + start() } - private class ExpectedReloadExceptionHandler(private val delegate: Thread.UncaughtExceptionHandler?) : Thread.UncaughtExceptionHandler { + internal companion object { + private const val RESTARTED_THREAD_NAME = "restartedMain" - override fun uncaughtException(t: Thread, e: Throwable) { - if (e is ExpectedReloadException) { - return - } + private val initLock = ReentrantLock() + lateinit var instance: Restarter + private set - if (delegate != null) { - delegate.uncaughtException(t, e) - } else { - e.printStackTrace() + internal fun initialize(config: BHotReloadConfig) { + initLock.withLock { + if (::instance.isInitialized.not()) { + instance = Restarter(config) + instance.initialize() + } } } - } - - internal companion object { - private const val RESTARTED_THREAD_NAME = "restartedMain" internal fun shouldRestart(config: BHotReloadConfig): Boolean { return config.enable && Thread.currentThread().name != RESTARTED_THREAD_NAME diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/RestarterListener.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/RestarterListener.kt new file mode 100644 index 000000000..a020c1a08 --- /dev/null +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/RestarterListener.kt @@ -0,0 +1,6 @@ +package io.github.freya022.botcommands.internal.core.reload + +interface RestarterListener { + + fun beforeRestart() +} \ No newline at end of file From 2833759a523064c3ab62d78969302d7bd8a7ac28 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Wed, 28 May 2025 19:41:59 +0200 Subject: [PATCH 06/45] Moved restarter to module, daemon internal coroutine dispatchers --- .../api/core/config/JDAConfiguration.kt | 4 + .../internal/core/SpringJDAShutdownHandler.kt | 27 +-- .../core/config/BotCommandsConfigurations.kt | 10 +- .../internal/core/config/ConfigProvider.kt | 4 +- .../handler/DefaultRateLimitHandler.kt | 2 +- .../freya022/botcommands/api/core/BContext.kt | 19 +- .../botcommands/api/core/BotCommands.kt | 10 +- .../botcommands/api/core/config/BConfig.kt | 16 ++ .../botcommands/api/core/db/Database.kt | 2 +- .../core/events/ApplicationStartListener.kt | 9 + .../api/core/events/BApplicationStartEvent.kt | 8 + .../api/core/events/BShutdownEvent.kt | 5 + .../botcommands/api/core/utils/Utils.kt | 4 + .../controller/ComponentTimeoutManager.kt | 1 - .../botcommands/internal/core/BContextImpl.kt | 201 +++++++++++++++++- .../core/hooks/EventDispatcherImpl.kt | 27 ++- .../core/reload/ExpectedReloadException.kt | 27 --- .../core/reload/HotReloadClassLoader.kt | 113 ---------- .../internal/core/reload/RestartScheduler.kt | 39 ---- .../internal/core/reload/Restarter.kt | 104 --------- .../internal/core/reload/RestarterListener.kt | 6 - .../internal/core/reload/SourceDirectories.kt | 44 ---- .../core/reload/SourceDirectoriesListener.kt | 5 - .../internal/core/reload/SourceDirectory.kt | 81 ------- .../core/reload/SourceDirectoryListener.kt | 5 - .../internal/core/reload/SourceFile.kt | 12 -- .../internal/core/reload/SourceFiles.kt | 16 -- .../core/service/BCBotCommandsBootstrap.kt | 18 +- .../botcommands/internal/utils/NIO.kt | 66 ------ .../internal/utils/ReflectionMetadata.kt | 16 ++ .../freya022/botcommands/reload/test/Bot.kt | 22 ++ .../freya022/botcommands/reload/test/Main.kt | 16 +- .../github/freya022/botcommands/test/Main.kt | 9 +- 33 files changed, 375 insertions(+), 573 deletions(-) create mode 100644 src/main/kotlin/io/github/freya022/botcommands/api/core/events/ApplicationStartListener.kt create mode 100644 src/main/kotlin/io/github/freya022/botcommands/api/core/events/BApplicationStartEvent.kt create mode 100644 src/main/kotlin/io/github/freya022/botcommands/api/core/events/BShutdownEvent.kt delete mode 100644 src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/ExpectedReloadException.kt delete mode 100644 src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/HotReloadClassLoader.kt delete mode 100644 src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/RestartScheduler.kt delete mode 100644 src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/Restarter.kt delete mode 100644 src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/RestarterListener.kt delete mode 100644 src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectories.kt delete mode 100644 src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectoriesListener.kt delete mode 100644 src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectory.kt delete mode 100644 src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectoryListener.kt delete mode 100644 src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceFile.kt delete mode 100644 src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceFiles.kt delete mode 100644 src/main/kotlin/io/github/freya022/botcommands/internal/utils/NIO.kt create mode 100644 src/test/kotlin/io/github/freya022/botcommands/reload/test/Bot.kt diff --git a/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/api/core/config/JDAConfiguration.kt b/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/api/core/config/JDAConfiguration.kt index 7e7a97d78..0fa5e8501 100644 --- a/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/api/core/config/JDAConfiguration.kt +++ b/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/api/core/config/JDAConfiguration.kt @@ -2,6 +2,7 @@ package io.github.freya022.botcommands.api.core.config import io.github.freya022.botcommands.api.core.JDAService import io.github.freya022.botcommands.internal.core.config.ConfigurationValue +import io.github.freya022.botcommands.internal.core.config.DeprecatedValue import io.github.freya022.botcommands.internal.core.config.IgnoreDefaultValue import net.dv8tion.jda.api.requests.GatewayIntent import net.dv8tion.jda.api.utils.cache.CacheFlag @@ -41,6 +42,7 @@ class JDAConfiguration internal constructor( val devTools: DevTools = DevTools(), ) { + // TODO deprecate in favor of BContextImpl's shutdown hook, which can be disabled class DevTools internal constructor( /** * When Spring devtools are enabled, @@ -59,6 +61,8 @@ class JDAConfiguration internal constructor( * Time to wait until JDA needs to be forcefully shut down, * in other words, this is the allowed time for a graceful shutdown. */ + @Deprecated("Replaced with botcommands.core.jdaShutdownTimeout") + @DeprecatedValue("Replaced with botcommands.core.jdaShutdownTimeout", replacement = "botcommands.core.jdaShutdownTimeout") @ConfigurationValue("jda.devtools.shutdownTimeout", type = "java.time.Duration", defaultValue = "10s") val shutdownTimeout: Duration = shutdownTimeout.toKotlinDuration() } diff --git a/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/SpringJDAShutdownHandler.kt b/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/SpringJDAShutdownHandler.kt index e5a998a5b..70e87760f 100644 --- a/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/SpringJDAShutdownHandler.kt +++ b/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/SpringJDAShutdownHandler.kt @@ -1,37 +1,20 @@ package io.github.freya022.botcommands.internal.core -import io.github.freya022.botcommands.api.core.config.JDAConfiguration -import io.github.freya022.botcommands.api.core.utils.awaitShutdown -import io.github.oshai.kotlinlogging.KotlinLogging -import net.dv8tion.jda.api.JDA +import io.github.freya022.botcommands.api.core.BContext import org.springframework.beans.factory.getBean import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.context.event.ContextClosedEvent import org.springframework.context.event.EventListener import org.springframework.stereotype.Component -private val logger = KotlinLogging.logger { } - @Component @ConditionalOnProperty(value = ["spring.devtools.restart.enabled", "jda.devtools.enabled"], havingValue = "true", matchIfMissing = true) -internal class SpringJDAShutdownHandler( - private val jdaConfiguration: JDAConfiguration, -) { +internal class SpringJDAShutdownHandler { @EventListener internal fun onContextClosed(event: ContextClosedEvent) { - logger.info { "Shutting down JDA" } - - val jda = event.applicationContext.getBean() - jda.shutdown() - - if (!jda.awaitShutdown(jdaConfiguration.devTools.shutdownTimeout)) { - logger.warn { "Timed out waiting for JDA to shutdown, forcing" } - - jda.shutdownNow() - jda.awaitShutdown() - } else { - logger.info { "JDA has gracefully shut down" } - } + val context = event.applicationContext.getBean() + context.shutdown() + context.awaitShutdown(context.config.shutdownTimeout) } } \ No newline at end of file diff --git a/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/BotCommandsConfigurations.kt b/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/BotCommandsConfigurations.kt index 3488634f1..a211dffd2 100644 --- a/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/BotCommandsConfigurations.kt +++ b/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/BotCommandsConfigurations.kt @@ -13,6 +13,7 @@ import org.springframework.boot.context.properties.bind.Name import java.time.Duration as JavaDuration import kotlin.io.path.Path import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds import kotlin.time.toKotlinDuration @ConfigurationProperties(prefix = "botcommands.core", ignoreUnknownFields = false) @@ -25,8 +26,12 @@ internal class BotCommandsCoreConfiguration( override val ignoredIntents: Set = emptySet(), override val ignoredEventIntents: Set> = emptySet(), override val ignoreRestRateLimiter: Boolean = false, + override val enableShutdownHook: Boolean = true, + shutdownTimeout: JavaDuration = JavaDuration.ofSeconds(10), ) : BConfig { + override val shutdownTimeout = shutdownTimeout.toKotlinDuration() + override val beforeStart: Nothing get() = unusable() override val classGraphProcessors: Nothing get() = unusable() override val serviceConfig: Nothing get() = unusable() @@ -41,7 +46,7 @@ internal class BotCommandsCoreConfiguration( override val hotReloadConfig: Nothing get() = unusable() } -internal fun BConfigBuilder.applyConfig(configuration: BotCommandsCoreConfiguration) = apply { +internal fun BConfigBuilder.applyConfig(configuration: BotCommandsCoreConfiguration, jdaConfiguration: JDAConfiguration) = apply { predefinedOwnerIds += configuration.predefinedOwnerIds packages += configuration.packages classes += configuration.classes @@ -50,6 +55,9 @@ internal fun BConfigBuilder.applyConfig(configuration: BotCommandsCoreConfigurat ignoredIntents += configuration.ignoredIntents ignoredEventIntents += configuration.ignoredEventIntents ignoreRestRateLimiter = configuration.ignoreRestRateLimiter + // If the new property has its default value, try to take the deprecated one + shutdownTimeout = configuration.shutdownTimeout.takeIf { it != 10.seconds } + ?: jdaConfiguration.devTools.shutdownTimeout } @ConfigurationProperties(prefix = "botcommands.database", ignoreUnknownFields = false) diff --git a/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/ConfigProvider.kt b/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/ConfigProvider.kt index 94dc064e0..8959c1693 100644 --- a/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/ConfigProvider.kt +++ b/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/ConfigProvider.kt @@ -10,7 +10,7 @@ internal open class ConfigProvider { @Bean @Primary internal open fun bConfig( - coreConfiguration: BotCommandsCoreConfiguration, coreConfigurers: List, + coreConfiguration: BotCommandsCoreConfiguration, coreConfigurers: List, jdaConfiguration: JDAConfiguration, databaseConfiguration: BotCommandsDatabaseConfiguration, databaseConfigurers: List, appEmojisConfiguration: BotCommandsAppEmojisConfiguration, appEmojisConfigurers: List, textConfiguration: BotCommandsTextConfiguration, textConfigurers: List, @@ -21,7 +21,7 @@ internal open class ConfigProvider { coroutineConfigurers: List ): BConfig = BConfigBuilder() - .applyConfig(coreConfiguration) + .applyConfig(coreConfiguration, jdaConfiguration) .apply { databaseConfig.applyConfig(databaseConfiguration).configure(databaseConfigurers) appEmojisConfig.applyConfig(appEmojisConfiguration).configure(appEmojisConfigurers) diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/commands/ratelimit/handler/DefaultRateLimitHandler.kt b/src/main/kotlin/io/github/freya022/botcommands/api/commands/ratelimit/handler/DefaultRateLimitHandler.kt index 5d58f68d2..3689716f8 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/api/commands/ratelimit/handler/DefaultRateLimitHandler.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/api/commands/ratelimit/handler/DefaultRateLimitHandler.kt @@ -29,7 +29,7 @@ import java.time.Instant import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.nanoseconds -private val deleteScope = namedDefaultScope("Rate limit message delete", 1) +private val deleteScope = namedDefaultScope("Rate limit message delete", 1, isDaemon = true) /** * Default [RateLimitHandler] implementation based on [rate limit scopes][RateLimitScope]. diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/core/BContext.kt b/src/main/kotlin/io/github/freya022/botcommands/api/core/BContext.kt index 0f6979c00..edfba95bd 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/api/core/BContext.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/api/core/BContext.kt @@ -11,6 +11,9 @@ import io.github.freya022.botcommands.api.core.service.annotations.InterfacedSer import io.github.freya022.botcommands.api.core.service.getService import io.github.freya022.botcommands.internal.core.exceptions.ServiceException import net.dv8tion.jda.api.JDA +import java.time.Duration as JavaDuration +import kotlin.time.Duration +import kotlin.time.toKotlinDuration /** * Main context for BotCommands framework. @@ -43,7 +46,11 @@ interface BContext { * * Fires [BReadyEvent]. */ - READY + READY, + + SHUTTING_DOWN, + + SHUTDOWN, } //region Configs @@ -144,6 +151,16 @@ interface BContext { */ fun getExceptionContent(message: String, t: Throwable?, extraContext: Map): String + fun shutdown() + + fun shutdownNow() + + fun awaitShutdown(): Boolean = awaitShutdown(Duration.INFINITE) + + fun awaitShutdown(timeout: JavaDuration): Boolean = awaitShutdown(timeout.toKotlinDuration()) + + fun awaitShutdown(timeout: Duration): Boolean + /** * Returns the [TextCommandsContext] service. * diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/core/BotCommands.kt b/src/main/kotlin/io/github/freya022/botcommands/api/core/BotCommands.kt index 2731e5947..8d22a7d4c 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/api/core/BotCommands.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/api/core/BotCommands.kt @@ -7,7 +7,6 @@ import io.github.freya022.botcommands.api.core.config.BConfigBuilder import io.github.freya022.botcommands.api.core.service.annotations.BService import io.github.freya022.botcommands.api.core.service.annotations.InterfacedService import io.github.freya022.botcommands.api.core.service.getService -import io.github.freya022.botcommands.internal.core.reload.Restarter import io.github.freya022.botcommands.internal.core.service.BCBotCommandsBootstrap import io.github.oshai.kotlinlogging.KotlinLogging import kotlin.time.DurationUnit @@ -61,16 +60,13 @@ object BotCommands { } private fun build(config: BConfig): BContext { - val hotReloadConfig = config.hotReloadConfig - if (Restarter.shouldRestart(hotReloadConfig)) { - Restarter.initialize(hotReloadConfig) - } - config.beforeStart?.invoke() val (context, duration) = measureTimedValue { val bootstrap = BCBotCommandsBootstrap(config) - bootstrap.injectAndLoadServices() + bootstrap.injectServices() + bootstrap.signalStart() + bootstrap.loadServices() bootstrap.loadContext() bootstrap.serviceContainer.getService() } diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BConfig.kt b/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BConfig.kt index a707d411a..e741e7c70 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BConfig.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BConfig.kt @@ -21,6 +21,8 @@ import net.dv8tion.jda.api.requests.GatewayIntent import net.dv8tion.jda.api.requests.RestRateLimiter import net.dv8tion.jda.api.utils.messages.MessageCreateData import org.intellij.lang.annotations.Language +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds @InjectedService interface BConfig { @@ -111,6 +113,14 @@ interface BConfig { // TODO is this really necessary? val beforeStart: (() -> Unit)? + @ConfigurationValue("botcommands.core.enableShutdownHook", defaultValue = "false") + val enableShutdownHook: Boolean + + // TODO java duration + // TODO this will apply only to hot restarts, move it to a BHotRestartConfig prob + @ConfigurationValue("botcommands.core.shutdownTimeout", type = "java.time.Duration", defaultValue = "10s") + val shutdownTimeout: Duration + val serviceConfig: BServiceConfig val databaseConfig: BDatabaseConfig val localizationConfig: BLocalizationConfig @@ -145,6 +155,10 @@ class BConfigBuilder : BConfig { override var beforeStart: (() -> Unit)? = null + override var enableShutdownHook: Boolean = true + + override var shutdownTimeout: Duration = 10.seconds + override val serviceConfig = BServiceConfigBuilder() override val databaseConfig = BDatabaseConfigBuilder() override val localizationConfig = BLocalizationConfigBuilder() @@ -302,6 +316,8 @@ class BConfigBuilder : BConfig { override val ignoreRestRateLimiter = this@BConfigBuilder.ignoreRestRateLimiter override val classGraphProcessors = this@BConfigBuilder.classGraphProcessors.toImmutableList() override val beforeStart = this@BConfigBuilder.beforeStart + override val enableShutdownHook = this@BConfigBuilder.enableShutdownHook + override val shutdownTimeout = this@BConfigBuilder.shutdownTimeout override val serviceConfig = this@BConfigBuilder.serviceConfig.build() override val databaseConfig = this@BConfigBuilder.databaseConfig.build() override val localizationConfig = this@BConfigBuilder.localizationConfig.build() diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/core/db/Database.kt b/src/main/kotlin/io/github/freya022/botcommands/api/core/db/Database.kt index c1dba23c0..b68cfe09e 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/api/core/db/Database.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/api/core/db/Database.kt @@ -166,7 +166,7 @@ internal fun Database.withStatementJava(sql: String, readOnly: Boolean = fal } @PublishedApi -internal val dbLeakScope = namedDefaultScope("Connection leak watcher", 1) +internal val dbLeakScope = namedDefaultScope("Connection leak watcher", 1, isDaemon = true) private val currentTransaction = ThreadLocal() diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/core/events/ApplicationStartListener.kt b/src/main/kotlin/io/github/freya022/botcommands/api/core/events/ApplicationStartListener.kt new file mode 100644 index 000000000..b3da7253b --- /dev/null +++ b/src/main/kotlin/io/github/freya022/botcommands/api/core/events/ApplicationStartListener.kt @@ -0,0 +1,9 @@ +package io.github.freya022.botcommands.api.core.events + +import io.github.freya022.botcommands.api.core.service.annotations.InterfacedService + +@InterfacedService(acceptMultiple = true) +fun interface ApplicationStartListener { + + fun onApplicationStart(event: BApplicationStartEvent) +} \ No newline at end of file diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/core/events/BApplicationStartEvent.kt b/src/main/kotlin/io/github/freya022/botcommands/api/core/events/BApplicationStartEvent.kt new file mode 100644 index 000000000..caaf9020f --- /dev/null +++ b/src/main/kotlin/io/github/freya022/botcommands/api/core/events/BApplicationStartEvent.kt @@ -0,0 +1,8 @@ +package io.github.freya022.botcommands.api.core.events + +import io.github.freya022.botcommands.api.core.config.BConfig + +class BApplicationStartEvent internal constructor( + val config: BConfig, + val args: List, +) \ No newline at end of file diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/core/events/BShutdownEvent.kt b/src/main/kotlin/io/github/freya022/botcommands/api/core/events/BShutdownEvent.kt new file mode 100644 index 000000000..89669b0cc --- /dev/null +++ b/src/main/kotlin/io/github/freya022/botcommands/api/core/events/BShutdownEvent.kt @@ -0,0 +1,5 @@ +package io.github.freya022.botcommands.api.core.events + +import io.github.freya022.botcommands.api.core.BContext + +class BShutdownEvent(context: BContext) : BEvent(context) \ No newline at end of file diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/core/utils/Utils.kt b/src/main/kotlin/io/github/freya022/botcommands/api/core/utils/Utils.kt index 149a6e11a..e4a3cedbe 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/api/core/utils/Utils.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/api/core/utils/Utils.kt @@ -69,6 +69,7 @@ fun withResource(url: String, block: (InputStream) -> R): R { * * @param name The base name of the threads and coroutines, will be prefixed by the number if [corePoolSize] > 1 * @param corePoolSize The number of threads to keep in the pool, even if they are idle + * @param isDaemon If the threads are daemons, the JVM exits when all threads are daemons * @param job The parent job used for coroutines which can be used to cancel all children, uses [SupervisorJob] by default * @param errorHandler The [CoroutineExceptionHandler] used for handling uncaught exceptions, * uses a logging handler which cancels the parent job on [Error] by default @@ -77,6 +78,7 @@ fun withResource(url: String, block: (InputStream) -> R): R { fun namedDefaultScope( name: String, corePoolSize: Int, + isDaemon: Boolean = false, job: Job? = null, errorHandler: CoroutineExceptionHandler? = null, context: CoroutineContext = EmptyCoroutineContext @@ -96,6 +98,8 @@ fun namedDefaultScope( this.name = "$name ${++count}" } } + + this.isDaemon = isDaemon } } diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/components/controller/ComponentTimeoutManager.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/components/controller/ComponentTimeoutManager.kt index 370ada49e..558ea131e 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/components/controller/ComponentTimeoutManager.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/components/controller/ComponentTimeoutManager.kt @@ -15,7 +15,6 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.datetime.Clock import kotlinx.datetime.Instant -import kotlin.collections.set private val logger = KotlinLogging.logger { } diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt index 258199e36..a7e4a5a4a 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt @@ -1,25 +1,47 @@ package io.github.freya022.botcommands.internal.core +import dev.minn.jda.ktx.events.CoroutineEventManager import io.github.freya022.botcommands.api.BCInfo import io.github.freya022.botcommands.api.core.BContext import io.github.freya022.botcommands.api.core.BContext.Status import io.github.freya022.botcommands.api.core.BotOwners import io.github.freya022.botcommands.api.core.GlobalExceptionHandler import io.github.freya022.botcommands.api.core.config.BConfig +import io.github.freya022.botcommands.api.core.config.BCoroutineScopesConfig +import io.github.freya022.botcommands.api.core.events.BShutdownEvent import io.github.freya022.botcommands.api.core.events.BStatusChangeEvent import io.github.freya022.botcommands.api.core.hooks.EventDispatcher import io.github.freya022.botcommands.api.core.service.ServiceContainer import io.github.freya022.botcommands.api.core.service.annotations.BService +import io.github.freya022.botcommands.api.core.service.getService import io.github.freya022.botcommands.api.core.service.getServiceOrNull import io.github.freya022.botcommands.api.core.service.lazy +import io.github.freya022.botcommands.api.core.utils.awaitShutdown import io.github.freya022.botcommands.api.core.utils.loggerOf import io.github.freya022.botcommands.internal.commands.application.ApplicationCommandsContextImpl import io.github.freya022.botcommands.internal.commands.text.TextCommandsContextImpl import io.github.freya022.botcommands.internal.utils.unwrap import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExecutorCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.datetime.Clock +import net.dv8tion.jda.api.JDA import net.dv8tion.jda.api.entities.Message +import net.dv8tion.jda.api.events.session.ShutdownEvent import net.dv8tion.jda.api.exceptions.ErrorHandler import net.dv8tion.jda.api.requests.ErrorResponse +import java.util.concurrent.ExecutorService +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import java.util.concurrent.locks.Condition +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock +import kotlin.reflect.full.declaredMemberProperties +import kotlin.reflect.jvm.jvmErasure +import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes private val logger = KotlinLogging.loggerOf() @@ -32,8 +54,8 @@ internal class BContextImpl internal constructor( ) : BContext { override val eventDispatcher: EventDispatcher by serviceContainer.lazy() - override var status: Status = Status.PRE_LOAD - private set + private val _status: AtomicReference = AtomicReference(Status.PRE_LOAD) + override val status: Status get() = _status.get() override val globalExceptionHandler: GlobalExceptionHandler? by lazy { serviceContainer.getServiceOrNull() } @@ -44,6 +66,17 @@ internal class BContextImpl internal constructor( private val bcRegex = Regex("at ${Regex.escape("io.github.freya022.botcommands.")}(?:api|internal)[.a-z]*\\.(.+)") private var nextExceptionDispatch: Long = 0 + private val statusLock: ReentrantLock = ReentrantLock() + private val statusCondition: Condition = statusLock.newCondition() + private val shutdownHook: Thread? = when { + config.enableShutdownHook -> Thread(::shutdownNow) + else -> null + } + + init { + shutdownHook?.let { Runtime.getRuntime().addShutdownHook(it) } + } + override fun dispatchException(message: String, t: Throwable?, extraContext: Map) { if (config.disableExceptionsInDMs) return //Don't send DM exceptions in dev mode @@ -118,8 +151,166 @@ internal class BContextImpl internal constructor( } internal suspend fun setStatus(newStatus: Status) { - val oldStatus = this.status - this.status = newStatus - eventDispatcher.dispatchEvent(BStatusChangeEvent(this@BContextImpl, oldStatus, newStatus)) + val oldStatus = statusLock.withLock { + val oldStatus = _status.getAndSet(newStatus) + statusCondition.signalAll() + oldStatus + } + if (oldStatus != newStatus) + eventDispatcher.dispatchEvent(BStatusChangeEvent(this@BContextImpl, oldStatus, newStatus)) + } + + override fun shutdown() { + if (status == Status.SHUTTING_DOWN || status == Status.SHUTDOWN) return + removeShutdownHook() + + runBlocking { setStatus(Status.SHUTTING_DOWN) } + + scheduleShutdownSignal() + + shutdownJDA(now = false) + shutdownEventManagerScope(now = false) + shutdownCoroutineScopes(now = false) + } + + override fun shutdownNow() { + shutdown() + + shutdownJDA(now = true) + shutdownEventManagerScope(now = true) + shutdownCoroutineScopes(now = true) + } + + /** + * Schedules a [BShutdownEvent] when all shards are shut down. + * + * A shard is considered shut down once its requester is shutdown, + * meaning no request can go through anymore. + * + * This does not necessarily mean all activities are stopped outside JDA. + */ + private fun scheduleShutdownSignal() { + fun signalShutdown() = runBlocking { + setStatus(Status.SHUTDOWN) + eventDispatcher.dispatchEvent(BShutdownEvent(this@BContextImpl)) + } + + val jda = jdaOrNull + if (jda == null) { + logger.debug { "Immediately sending shutdown signal as there is no JDA instance registered" } + signalShutdown() + return + } + + val countdown = AtomicInteger(jda.shardManager?.shardsRunning ?: 1) + val shards = jda.shardManager?.shards ?: listOf(jda) + shards.forEach { + it.listenOnce(ShutdownEvent::class.java).subscribe { + if (countdown.decrementAndGet() == 0) { + signalShutdown() + } + } + } + } + + override fun awaitShutdown(timeout: Duration): Boolean { + val deadline = Clock.System.now() + timeout + fun durationUntilDeadline(): Duration = deadline - Clock.System.now() + + if (!awaitJDAShutdown(::durationUntilDeadline)) + return false + + statusLock.withLock { + while (status != Status.SHUTDOWN) { + if (!statusCondition.await(durationUntilDeadline().inWholeMilliseconds, TimeUnit.MILLISECONDS)) { + return false + } + } + } + + return true } + + private fun awaitJDAShutdown(durationUntilDeadlineFn: () -> Duration): Boolean { + val jda = jdaOrNull + if (jda == null) { + logger.debug { "Not awaiting JDA shutdown as there is no JDA instance registered" } + return true + } + val shardManager = jda.shardManager + if (shardManager != null) { + shardManager.shards.forEach { shard -> + if (!shard.awaitShutdown(durationUntilDeadlineFn())) { + return false + } + } + } else { + if (!jda.awaitShutdown(durationUntilDeadlineFn())) { + return false + } + } + + return true + } + + private fun removeShutdownHook() { + if (shutdownHook == null) return + + try { + Runtime.getRuntime().removeShutdownHook(shutdownHook) + } catch (_: IllegalStateException) { + + } + } + + private fun shutdownJDA(now: Boolean) { + val jda = jdaOrNull ?: return logger.debug { "Ignoring JDA shutdown as there is no JDA instance registered" } + + val shardManager = jda.shardManager + if (shardManager != null) { + shardManager.shutdown() + + if (now) { + shardManager.shardCache.forEach { jda -> + // The shard manager may not be configured to shut down immediately, + // so we try to force it here + jda.shutdownNow() + } + } + } else { + if (now) { + jda.shutdownNow() + } else { + jda.shutdown() + } + } + } + + private fun shutdownEventManagerScope(now: Boolean) { + getService().shutdownExecutor(now = now) + } + + private fun shutdownCoroutineScopes(now: Boolean) { + BCoroutineScopesConfig::class + .declaredMemberProperties + .asSequence() + .filter { it.returnType.jvmErasure == CoroutineScope::class } + .map { it.get(coroutineScopesConfig) } + .map { it as CoroutineScope } + .forEach { + it.shutdownExecutor(now = now) + } + } + + private fun CoroutineScope.shutdownExecutor(now: Boolean) { + val executor = coroutineContext[ExecutorCoroutineDispatcher]?.executor as? ExecutorService ?: return + if (now) { + cancel("Cancelled by shutdown") + executor.shutdownNow() + } else { + executor.shutdown() + } + } + + private val jdaOrNull: JDA? get() = getServiceOrNull() } diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/EventDispatcherImpl.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/EventDispatcherImpl.kt index 7a4f28f9e..1f0469566 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/EventDispatcherImpl.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/EventDispatcherImpl.kt @@ -23,7 +23,12 @@ internal class EventDispatcherImpl internal constructor( ) : EventDispatcher() { private val inheritedCoroutineScope: CoroutineScope = originalCoroutineEventManager + private val inheritedCoroutineScopeExecutorDispatcher: ExecutorCoroutineDispatcher? = + originalCoroutineEventManager.coroutineContext[ExecutorCoroutineDispatcher] + private val asyncCoroutineScope: CoroutineScope = coroutineScopesConfig.eventDispatcherScope + private val asyncCoroutineScopeExecutorDispatcher: ExecutorCoroutineDispatcher? = + asyncCoroutineScope.coroutineContext[ExecutorCoroutineDispatcher] internal fun onEvent(event: GenericEvent) { // No need to check for `event` type as if it's in the map, then it's recognized @@ -39,13 +44,21 @@ internal class EventDispatcherImpl internal constructor( } // When the listener requests to run async - handlers[RunMode.ASYNC]?.forEach { eventHandler -> + handlers[RunMode.ASYNC]?.let { eventHandlers -> + if (!asyncCoroutineScope.isActive) + return@let runBlocking { eventHandlers.forEach { runEventHandler(it, event) } } + asyncCoroutineScope.launch { - runEventHandler(eventHandler, event) + eventHandlers.forEach { eventHandler -> + runEventHandler(eventHandler, event) + } } } handlers[RunMode.SHARED]?.let { eventHandlers -> + if (!inheritedCoroutineScope.isActive) + return@let runBlocking { eventHandlers.forEach { runEventHandler(it, event) } } + // Stick to what JDA-KTX does, 1 coroutine per event for all listeners inheritedCoroutineScope.launch { eventHandlers.forEach { eventHandler -> @@ -69,9 +82,15 @@ internal class EventDispatcherImpl internal constructor( } // When the listener requests to run async - handlers[RunMode.ASYNC]?.forEach { eventHandler -> + handlers[RunMode.ASYNC]?.let { eventHandlers -> + // TODO make sure this actually checks if the thread pool is open + if (!asyncCoroutineScope.isActive) + return@let runBlocking { eventHandlers.forEach { runEventHandler(it, event) } } + asyncCoroutineScope.launch { - runEventHandler(eventHandler, event) + eventHandlers.forEach { eventHandler -> + runEventHandler(eventHandler, event) + } } } diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/ExpectedReloadException.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/ExpectedReloadException.kt deleted file mode 100644 index 65928b461..000000000 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/ExpectedReloadException.kt +++ /dev/null @@ -1,27 +0,0 @@ -package io.github.freya022.botcommands.internal.core.reload - -internal class ExpectedReloadException : RuntimeException("Dummy exception to stop the execution of the first main thread") { - - companion object { - fun throwAndHandle(): Nothing { - val currentThread = Thread.currentThread() - currentThread.uncaughtExceptionHandler = ExpectedReloadExceptionHandler(currentThread.uncaughtExceptionHandler) - throw ExpectedReloadException() - } - } - - private class ExpectedReloadExceptionHandler(private val delegate: Thread.UncaughtExceptionHandler?) : Thread.UncaughtExceptionHandler { - - override fun uncaughtException(t: Thread, e: Throwable) { - if (e is ExpectedReloadException) { - return - } - - if (delegate != null) { - delegate.uncaughtException(t, e) - } else { - e.printStackTrace() - } - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/HotReloadClassLoader.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/HotReloadClassLoader.kt deleted file mode 100644 index 01e9d02a8..000000000 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/HotReloadClassLoader.kt +++ /dev/null @@ -1,113 +0,0 @@ -package io.github.freya022.botcommands.internal.core.reload - -import java.io.InputStream -import java.net.URL -import java.net.URLClassLoader -import java.net.URLConnection -import java.net.URLStreamHandler -import java.util.* - -internal class HotReloadClassLoader( - urls: List, - parent: ClassLoader, - private val sourceDirectories: SourceDirectories, -) : URLClassLoader(urls.toTypedArray(), parent) { - - override fun getResources(name: String): Enumeration { - val resources = parent.getResources(name) - val updatedFile = sourceDirectories.getFile(name) - - if (updatedFile != null) { - if (resources.hasMoreElements()) { - resources.nextElement() - } - if (updatedFile is SourceFile) { - return MergedEnumeration(createFileUrl(name, updatedFile), resources) - } - } - - return resources - } - - override fun getResource(name: String): URL? { - val updatedFile = sourceDirectories.getFile(name) - if (updatedFile is DeletedSourceFile) { - return null - } - - return findResource(name) ?: super.getResource(name) - } - - override fun findResource(name: String): URL? { - val updatedFile = sourceDirectories.getFile(name) - ?: return super.findResource(name) - return (updatedFile as? SourceFile)?.let { createFileUrl(name, it) } - } - - override fun loadClass(name: String, resolve: Boolean): Class<*> { - val path = "${name.replace('.', '/')}.class" - val updatedFile = sourceDirectories.getFile(path) - if (updatedFile is DeletedSourceFile) - throw ClassNotFoundException(name) - - return synchronized(getClassLoadingLock(name)) { - val loadedClass = findLoadedClass(name) ?: try { - findClass(name) - } catch (_: ClassNotFoundException) { - Class.forName(name, false, parent) - } - if (resolve) resolveClass(loadedClass) - loadedClass - } - } - - override fun findClass(name: String): Class<*> { - val path = "${name.replace('.', '/')}.class" - val updatedFile = sourceDirectories.getFile(path) - ?: return super.findClass(name) - if (updatedFile is DeletedSourceFile) - throw ClassNotFoundException(name) - - updatedFile as SourceFile - return defineClass(name, updatedFile.bytes, 0, updatedFile.bytes.size) - } - - @Suppress("DEPRECATION") // We target Java 17 but JDK 20 deprecates the URL constructors - private fun createFileUrl(name: String, file: SourceFile): URL { - return URL("reloaded", null, -1, "/$name", ClasspathFileURLStreamHandler(file)) - } - - private class ClasspathFileURLStreamHandler( - private val file: SourceFile, - ) : URLStreamHandler() { - - override fun openConnection(u: URL): URLConnection = Connection(u) - - private inner class Connection(url: URL): URLConnection(url) { - - override fun connect() {} - - override fun getInputStream(): InputStream = file.bytes.inputStream() - - override fun getLastModified(): Long = file.lastModified.toEpochMilli() - - override fun getContentLengthLong(): Long = file.bytes.size.toLong() - } - } - - private class MergedEnumeration(private val first: E, private val rest: Enumeration) : Enumeration { - - private var hasConsumedFirst = false - - override fun hasMoreElements(): Boolean = !hasConsumedFirst || rest.hasMoreElements() - - override fun nextElement(): E? { - if (!hasConsumedFirst) { - hasConsumedFirst = true - return first - } else { - return rest.nextElement() - } - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/RestartScheduler.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/RestartScheduler.kt deleted file mode 100644 index 27e77df5d..000000000 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/RestartScheduler.kt +++ /dev/null @@ -1,39 +0,0 @@ -package io.github.freya022.botcommands.internal.core.reload - -import io.github.freya022.botcommands.api.core.config.BHotReloadConfig -import java.util.concurrent.Executors -import java.util.concurrent.ScheduledFuture -import java.util.concurrent.TimeUnit -import java.util.concurrent.locks.ReentrantLock -import kotlin.concurrent.withLock - -internal class RestartScheduler( - private val hotReloadConfig: BHotReloadConfig, - private val restarter: Restarter, -) { - - private val scheduler = Executors.newSingleThreadScheduledExecutor() - private lateinit var scheduledRestart: ScheduledFuture<*> - - private val commands: MutableList<() -> Unit> = arrayListOf() - private val lock = ReentrantLock() - - /** - * This method queues the provided [command] and executes them before restarting, - * allowing to only load binaries after everything has been compiled - */ - internal fun queueClasspathChange(command: () -> Unit): Unit = lock.withLock { - commands += command - if (::scheduledRestart.isInitialized) scheduledRestart.cancel(false) - - scheduledRestart = scheduler.schedule({ - lock.withLock { - commands.forEach { it.invoke() } - commands.clear() - } - println("Restart!") - restarter.restart() - scheduler.shutdown() - }, hotReloadConfig.restartDelay.inWholeMilliseconds, TimeUnit.MILLISECONDS) - } -} \ No newline at end of file diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/Restarter.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/Restarter.kt deleted file mode 100644 index 919eda5d9..000000000 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/Restarter.kt +++ /dev/null @@ -1,104 +0,0 @@ -package io.github.freya022.botcommands.internal.core.reload - -import io.github.freya022.botcommands.api.core.config.BHotReloadConfig -import io.github.freya022.botcommands.internal.utils.stackWalker -import io.github.oshai.kotlinlogging.KotlinLogging -import java.io.File -import java.lang.management.ManagementFactory -import java.net.URL -import java.util.concurrent.locks.ReentrantLock -import kotlin.concurrent.thread -import kotlin.concurrent.withLock -import kotlin.io.path.isDirectory -import kotlin.io.path.toPath - -private val logger = KotlinLogging.logger { } - -internal class Restarter private constructor( - private val hotReloadConfig: BHotReloadConfig -) { - - private val mainMethodClassName: String - private val classpathUrls: List - private val baseClassLoader: ClassLoader - - private val sourceDirectories: SourceDirectories - - private val restartScheduler = RestartScheduler(hotReloadConfig, this) - - private val listeners: MutableList = arrayListOf() - - init { - check(Thread.currentThread().name == "main") - - val mainMethodFrame = stackWalker.walk { stream -> stream.filter { it.methodName == "main" }.toList().last() } - mainMethodClassName = mainMethodFrame.declaringClass.name - - classpathUrls = ManagementFactory.getRuntimeMXBean().classPath - .split(File.pathSeparator) - .map { File(it).toURI().toURL() } - baseClassLoader = Thread.currentThread().contextClassLoader!! - - sourceDirectories = classpathUrls - .map { it.toURI().toPath() } - .filter { it.isDirectory() } - .let { SourceDirectories(it, listener = restartScheduler::queueClasspathChange) } - } - - internal fun addListener(listener: RestarterListener) { - listeners += listener - } - - private fun stop() { - listeners.forEach { it.beforeRestart() } - } - - private fun start() { - val classLoader = HotReloadClassLoader( - urls = classpathUrls, - parent = baseClassLoader, - sourceDirectories, - ) - thread(name = RESTARTED_THREAD_NAME, contextClassLoader = classLoader) { - try { - val mainClass = classLoader.loadClass(mainMethodClassName) - val mainMethod = mainClass.getDeclaredMethod("main", Array::class.java) - mainMethod.isAccessible = true - mainMethod.invoke(null, hotReloadConfig.args.toTypedArray()) - } catch (e: Throwable) { - logger.error(e) { "An error occurred while running the main class" } - } - } - } - - private fun initialize(): Nothing { - start() - ExpectedReloadException.throwAndHandle() - } - - internal fun restart() { - stop() - start() - } - - internal companion object { - private const val RESTARTED_THREAD_NAME = "restartedMain" - - private val initLock = ReentrantLock() - lateinit var instance: Restarter - private set - - internal fun initialize(config: BHotReloadConfig) { - initLock.withLock { - if (::instance.isInitialized.not()) { - instance = Restarter(config) - instance.initialize() - } - } - } - - internal fun shouldRestart(config: BHotReloadConfig): Boolean { - return config.enable && Thread.currentThread().name != RESTARTED_THREAD_NAME - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/RestarterListener.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/RestarterListener.kt deleted file mode 100644 index a020c1a08..000000000 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/RestarterListener.kt +++ /dev/null @@ -1,6 +0,0 @@ -package io.github.freya022.botcommands.internal.core.reload - -interface RestarterListener { - - fun beforeRestart() -} \ No newline at end of file diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectories.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectories.kt deleted file mode 100644 index e748d2fa6..000000000 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectories.kt +++ /dev/null @@ -1,44 +0,0 @@ -package io.github.freya022.botcommands.internal.core.reload - -import java.nio.file.Path - -internal class SourceDirectories internal constructor() { - private val directories: MutableMap = hashMapOf() - - internal fun getFile(path: String): ISourceFile? { - return directories.firstNotNullOfOrNull { it.value.files[path] } - } - - internal fun setSource(source: SourceDirectory) { - directories[source.directory] = source - } - - internal fun replaceSource(key: Path, directory: SourceDirectory) { - check(key in directories) - - directories[key] = directory - } -} - -internal fun SourceDirectories(directories: List, listener: SourceDirectoriesListener): SourceDirectories { - val sourceDirectories = SourceDirectories() - - fun onSourceDirectoryUpdate(directory: Path, sourceFilesFactory: () -> SourceFiles) { - // The command is called when restarting - // so we don't make snapshots before all changes went through - listener.onChange(command = { - val newSourceDirectory = SourceDirectory( - directory, - sourceFilesFactory(), - listener = { onSourceDirectoryUpdate(directory, it) } - ) - sourceDirectories.replaceSource(directory, newSourceDirectory) - }) - } - - directories.forEach { directory -> - sourceDirectories.setSource(SourceDirectory(directory, listener = { onSourceDirectoryUpdate(directory, it) })) - } - - return sourceDirectories -} \ No newline at end of file diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectoriesListener.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectoriesListener.kt deleted file mode 100644 index 75d7a7ac5..000000000 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectoriesListener.kt +++ /dev/null @@ -1,5 +0,0 @@ -package io.github.freya022.botcommands.internal.core.reload - -internal fun interface SourceDirectoriesListener { - fun onChange(command: () -> Unit) -} \ No newline at end of file diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectory.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectory.kt deleted file mode 100644 index a93244b5d..000000000 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectory.kt +++ /dev/null @@ -1,81 +0,0 @@ -package io.github.freya022.botcommands.internal.core.reload - -import io.github.freya022.botcommands.internal.utils.throwInternal -import io.github.freya022.botcommands.internal.utils.walkDirectories -import io.github.freya022.botcommands.internal.utils.walkFiles -import io.github.oshai.kotlinlogging.KotlinLogging -import java.nio.file.Path -import java.nio.file.StandardWatchEventKinds.* -import kotlin.concurrent.thread -import kotlin.io.path.* - -private val logger = KotlinLogging.logger { } - -@OptIn(ExperimentalPathApi::class) -internal class SourceDirectory internal constructor( - val directory: Path, - val files: SourceFiles, - private val listener: SourceDirectoryListener, -) { - - init { - require(directory.isDirectory()) - - logger.debug { "Listening to ${directory.absolutePathString()}" } - - val watchService = directory.fileSystem.newWatchService() - directory.walkDirectories { path, attributes -> - path.register(watchService, ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE) - } - - thread(name = "Classpath watcher of '${directory.fileName}'", isDaemon = true) { - watchService.take() // Wait for a change - watchService.close() - - listener.onChange(sourcesFilesFactory = { - val snapshot = directory.takeSnapshot() - - // Exclude deleted files so they don't count as being deleted again - val deletedPaths = files.withoutDeletes().keys - snapshot.keys - if (deletedPaths.isNotEmpty()) { - logger.info { "Deleted files in ${directory.absolutePathString()}: $deletedPaths" } - return@onChange deletedPaths.associateWith { DeletedSourceFile } + snapshot - } - - // Exclude deleted files so they count as being added back - val addedPaths = snapshot.keys - files.withoutDeletes().keys - if (addedPaths.isNotEmpty()) { - logger.info { "Added files in ${directory.absolutePathString()}: $addedPaths" } - return@onChange files + snapshot - } - - val modifiedFiles = snapshot.keys.filter { key -> - val actual = snapshot[key] ?: throwInternal("Key from map is missing a value somehow") - val expected = files[key] ?: throwInternal("Expected file is missing, should have been detected as deleted") - - // File was deleted (on the 2nd build for example) and got recreated (on the 3rd build for example) - if (expected is DeletedSourceFile) throwInternal("Expected file was registered as deleted, should have been detected as added") - expected as SourceFile - - actual as SourceFile // Assertion - - actual.lastModified != expected.lastModified - } - if (modifiedFiles.isNotEmpty()) { - logger.info { "Timestamp changed in ${directory.absolutePathString()}: $modifiedFiles" } - return@onChange files + snapshot - } - - throwInternal("Received a file system event but no changes were detected") - }) - } - } -} - -internal fun SourceDirectory(directory: Path, listener: SourceDirectoryListener): SourceDirectory { - return SourceDirectory(directory, directory.takeSnapshot(), listener) -} - -private fun Path.takeSnapshot(): SourceFiles = walkFiles().associate { (it, attrs) -> - it.relativeTo(this).pathString to SourceFile(attrs.lastModifiedTime().toInstant(), it.readBytes()) -}.let(::SourceFiles) diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectoryListener.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectoryListener.kt deleted file mode 100644 index 3841654bd..000000000 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceDirectoryListener.kt +++ /dev/null @@ -1,5 +0,0 @@ -package io.github.freya022.botcommands.internal.core.reload - -internal fun interface SourceDirectoryListener { - fun onChange(sourcesFilesFactory: () -> SourceFiles) -} \ No newline at end of file diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceFile.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceFile.kt deleted file mode 100644 index 7bb5be8bf..000000000 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceFile.kt +++ /dev/null @@ -1,12 +0,0 @@ -package io.github.freya022.botcommands.internal.core.reload - -import java.time.Instant - -internal sealed interface ISourceFile - -internal class SourceFile( - val lastModified: Instant, - val bytes: ByteArray, -) : ISourceFile - -internal object DeletedSourceFile : ISourceFile \ No newline at end of file diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceFiles.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceFiles.kt deleted file mode 100644 index 9f3310b99..000000000 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/reload/SourceFiles.kt +++ /dev/null @@ -1,16 +0,0 @@ -package io.github.freya022.botcommands.internal.core.reload - -internal class SourceFiles internal constructor( - internal val files: Map, -) { - - val keys: Set get() = files.keys - - internal operator fun get(path: String): ISourceFile? = files[path] - - internal fun withoutDeletes(): SourceFiles = SourceFiles(files.filterValues { it !is DeletedSourceFile }) - - internal operator fun plus(other: SourceFiles): SourceFiles = SourceFiles(files + other.files) -} - -internal operator fun Map.plus(other: SourceFiles): SourceFiles = SourceFiles(this + other.files) \ No newline at end of file diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCBotCommandsBootstrap.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCBotCommandsBootstrap.kt index 9c4d94a1c..1fb878422 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCBotCommandsBootstrap.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCBotCommandsBootstrap.kt @@ -4,10 +4,9 @@ import io.github.classgraph.ClassInfo import io.github.classgraph.MethodInfo import io.github.freya022.botcommands.api.BCInfo import io.github.freya022.botcommands.api.core.config.* -import io.github.freya022.botcommands.api.core.service.ClassGraphProcessor -import io.github.freya022.botcommands.api.core.service.ServiceContainer -import io.github.freya022.botcommands.api.core.service.putServiceAs -import io.github.freya022.botcommands.api.core.service.putServiceWithTypeAlias +import io.github.freya022.botcommands.api.core.events.ApplicationStartListener +import io.github.freya022.botcommands.api.core.events.BApplicationStartEvent +import io.github.freya022.botcommands.api.core.service.* import io.github.freya022.botcommands.internal.core.Version import io.github.freya022.botcommands.internal.core.service.provider.ServiceProviders import net.dv8tion.jda.api.JDAInfo @@ -31,7 +30,7 @@ internal class BCBotCommandsBootstrap internal constructor( init() } - internal fun injectAndLoadServices() = measure("Created services") { + internal fun injectServices() { serviceContainer.putServiceWithTypeAlias(this) serviceContainer.putServiceWithTypeAlias(serviceContainer) @@ -48,7 +47,16 @@ internal class BCBotCommandsBootstrap internal constructor( serviceContainer.putServiceAs(config.coroutineScopesConfig) serviceContainer.putServiceAs(config.textConfig) serviceContainer.putServiceAs(config.hotReloadConfig) + } + + // This does not use the usual event dispatcher to avoid more delays + internal fun signalStart() = measure("Signaled application start") { + val startEvent = BApplicationStartEvent(config, emptyList() /* TODO */) + val startListeners = serviceContainer.getInterfacedServices() + startListeners.forEach { it.onApplicationStart(startEvent) } + } + internal fun loadServices() = measure("Created services") { serviceContainer.loadServices() } diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/utils/NIO.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/utils/NIO.kt deleted file mode 100644 index 469c61680..000000000 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/utils/NIO.kt +++ /dev/null @@ -1,66 +0,0 @@ -package io.github.freya022.botcommands.internal.utils - -import java.io.IOException -import java.nio.file.FileVisitResult -import java.nio.file.FileVisitor -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.attribute.BasicFileAttributes - -// Optimization of Path#walk, cuts CPU usage by 4 -// mostly by eliminating duplicate calls to file attributes -fun Path.walkFiles(): List> { - return buildList { - Files.walkFileTree(this@walkFiles, object : FileVisitor { - override fun preVisitDirectory( - dir: Path, - attrs: BasicFileAttributes - ): FileVisitResult = FileVisitResult.CONTINUE - - override fun visitFile( - file: Path, - attrs: BasicFileAttributes - ): FileVisitResult { - add(file to attrs) - return FileVisitResult.CONTINUE - } - - override fun visitFileFailed( - file: Path, - exc: IOException - ): FileVisitResult = FileVisitResult.CONTINUE - - override fun postVisitDirectory( - dir: Path, - exc: IOException? - ): FileVisitResult = FileVisitResult.CONTINUE - }) - } -} - -fun Path.walkDirectories(block: (Path, BasicFileAttributes) -> Unit) { - Files.walkFileTree(this@walkDirectories, object : FileVisitor { - override fun preVisitDirectory( - dir: Path, - attrs: BasicFileAttributes - ): FileVisitResult { - block(dir, attrs) - return FileVisitResult.CONTINUE - } - - override fun visitFile( - file: Path, - attrs: BasicFileAttributes - ): FileVisitResult = FileVisitResult.CONTINUE - - override fun visitFileFailed( - file: Path, - exc: IOException - ): FileVisitResult = FileVisitResult.CONTINUE - - override fun postVisitDirectory( - dir: Path, - exc: IOException? - ): FileVisitResult = FileVisitResult.CONTINUE - }) -} \ No newline at end of file diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/utils/ReflectionMetadata.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/utils/ReflectionMetadata.kt index 9396e622f..d483e1090 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/utils/ReflectionMetadata.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/utils/ReflectionMetadata.kt @@ -103,9 +103,24 @@ private class ReflectionMetadataScanner private constructor( logger.debug { "Scanning classes: ${classes.joinToString { it.simpleNestedName }}" } ClassGraph() +// .let { +// val thread = Thread.currentThread() +// if (thread.name != Restarter.RESTARTED_THREAD_NAME) return@let it +// +// val classLoader = thread.contextClassLoader +// // Cannot compare using `is` +// // as HotReloadClassLoader::class would be from the current classloader, +// // while the context class loader is loaded from the app's class loader +// if (classLoader.javaClass.name != HotReloadClassLoader::class.java.name) return@let it +// +// // [[HotReloadClassLoader]] will read from its files first then delegate to the app class loader anyway +// it.overrideClassLoaders(classLoader) +// } .acceptPackages( "io.github.freya022.botcommands.api", "io.github.freya022.botcommands.internal", + "dev.freya02.botcommands.api", + "dev.freya02.botcommands.internal", *packages.toTypedArray() ) .acceptClasses(*classes.mapToArray { it.name }) @@ -140,6 +155,7 @@ private class ReflectionMetadataScanner private constructor( private fun ClassInfo.isFromLib() = packageName.startsWith("io.github.freya022.botcommands.api") || packageName.startsWith("io.github.freya022.botcommands.internal") + || packageName.startsWith("dev.freya02.botcommands.api") || packageName.startsWith("dev.freya02.botcommands.internal") private fun List.filterLibraryClasses(): List { // Get types referenced by factories so we get metadata from those as well diff --git a/src/test/kotlin/io/github/freya022/botcommands/reload/test/Bot.kt b/src/test/kotlin/io/github/freya022/botcommands/reload/test/Bot.kt new file mode 100644 index 000000000..32d4b7bb8 --- /dev/null +++ b/src/test/kotlin/io/github/freya022/botcommands/reload/test/Bot.kt @@ -0,0 +1,22 @@ +package io.github.freya022.botcommands.reload.test + +import io.github.freya022.botcommands.api.core.JDAService +import io.github.freya022.botcommands.api.core.events.BReadyEvent +import io.github.freya022.botcommands.api.core.service.annotations.BService +import net.dv8tion.jda.api.hooks.IEventManager +import net.dv8tion.jda.api.requests.GatewayIntent +import net.dv8tion.jda.api.utils.cache.CacheFlag + +@BService +class Bot : JDAService() { + + override val intents: Set = emptySet() + override val cacheFlags: Set = emptySet() + + override fun createJDA( + event: BReadyEvent, + eventManager: IEventManager + ) { + println("JDA") + } +} \ No newline at end of file diff --git a/src/test/kotlin/io/github/freya022/botcommands/reload/test/Main.kt b/src/test/kotlin/io/github/freya022/botcommands/reload/test/Main.kt index 714425cba..54ed99d76 100644 --- a/src/test/kotlin/io/github/freya022/botcommands/reload/test/Main.kt +++ b/src/test/kotlin/io/github/freya022/botcommands/reload/test/Main.kt @@ -1,17 +1,19 @@ package io.github.freya022.botcommands.reload.test import ch.qos.logback.classic.ClassicConstants -import dev.reformator.stacktracedecoroutinator.jvm.DecoroutinatorJvmApi +import dev.freya02.botcommands.internal.restart.Restarter import io.github.freya022.botcommands.api.core.BotCommands import io.github.freya022.botcommands.api.core.config.DevConfig import io.github.freya022.botcommands.api.core.reload.ExperimentalHotReloadApi import io.github.freya022.botcommands.test.config.Environment import io.github.oshai.kotlinlogging.KotlinLogging import java.lang.management.ManagementFactory +import kotlin.concurrent.thread import kotlin.io.path.absolutePathString private val logger by lazy { KotlinLogging.logger { } } +@OptIn(ExperimentalHotReloadApi::class) fun main(args: Array) { System.setProperty(ClassicConstants.CONFIG_FILE_PROPERTY, Environment.logbackConfigPath.absolutePathString()) logger.info { "Loading logback configuration at ${Environment.logbackConfigPath.absolutePathString()}" } @@ -26,7 +28,7 @@ fun main(args: Array) { } else if ("--no-decoroutinator" in args) { logger.info { "Skipping stacktrace-decoroutinator as --no-decoroutinator is specified" } } else { - DecoroutinatorJvmApi.install() +// DecoroutinatorJvmApi.install() } BotCommands.create { @@ -34,8 +36,6 @@ fun main(args: Array) { addSearchPath("io.github.freya022.botcommands.reload.test") - beforeStart = { Thread.sleep(Long.MAX_VALUE) } - textCommands { enable = false } @@ -60,4 +60,12 @@ fun main(args: Array) { this.args = args.toList() } } + + thread(isDaemon = true) { + println("Reading input") + + val line = readln() + if (line == "restart") + Restarter.instance.restart() + } } \ No newline at end of file diff --git a/src/test/kotlin/io/github/freya022/botcommands/test/Main.kt b/src/test/kotlin/io/github/freya022/botcommands/test/Main.kt index b16c97ac3..2ea8e9e9a 100644 --- a/src/test/kotlin/io/github/freya022/botcommands/test/Main.kt +++ b/src/test/kotlin/io/github/freya022/botcommands/test/Main.kt @@ -8,6 +8,7 @@ import io.github.freya022.botcommands.test.config.Environment import io.github.oshai.kotlinlogging.KotlinLogging import net.dv8tion.jda.api.interactions.DiscordLocale import java.lang.management.ManagementFactory +import kotlin.concurrent.thread import kotlin.io.path.absolutePathString import kotlin.system.exitProcess import kotlin.time.Duration.Companion.milliseconds @@ -36,7 +37,7 @@ object Main { DecoroutinatorJvmApi.install() } - BotCommands.create { + val ctx = BotCommands.create { disableExceptionsInDMs = true addSearchPath("io.github.freya022.botcommands.test") @@ -81,6 +82,12 @@ object Main { enable = true } } + + thread(isDaemon = true) { + val line = readln() + if (line == "exit") + exitProcess(0) + } } catch (e: Exception) { logger.error(e) { "Could not start the test bot" } exitProcess(1) From a1ff53b0446c53c1d25a5c47a661a1d4c9c5e18f Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Fri, 30 May 2025 16:29:54 +0200 Subject: [PATCH 07/45] notes --- .../freya022/botcommands/api/core/config/BHotReloadConfig.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BHotReloadConfig.kt b/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BHotReloadConfig.kt index 97209ff31..ed5d69b45 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BHotReloadConfig.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BHotReloadConfig.kt @@ -22,6 +22,9 @@ interface BHotReloadConfig { @ConfigDSL class BHotReloadConfigBuilder : BHotReloadConfig { + // TODO do not make "enable" and "args" separately mutable, add a "enable" method with takes args and sets both + // so the user does not get surprises + // also add disable() for parity @set:JvmName("enable") override var enable: Boolean = false From b3d28e030e10cc1f61300d52d3c282e098f382c9 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Sat, 31 May 2025 14:42:04 +0200 Subject: [PATCH 08/45] Move RequiresDefaultInjection to API --- .../core/service/annotations/RequiresDefaultInjection.kt | 4 ++-- .../internal/core/service/BCClassAnnotationsMap.kt | 2 +- .../internal/core/service/BCInstantiableServices.kt | 2 +- .../botcommands/test/commands/slash/SlashDIJava.java | 2 +- .../freya022/botcommands/test/commands/slash/SlashDI.kt | 2 +- .../test/commands/slash/SlashDynamicTypedResolver.kt | 2 +- .../freya022/botcommands/test/resolvers/MapResolver.kt | 2 +- .../botcommands/test/services/FactoryServiceTest.kt | 6 +----- .../freya022/botcommands/test/services/NamedService.kt | 2 +- .../botcommands/test/services/NonUniqueProviderTest.kt | 2 +- 10 files changed, 11 insertions(+), 15 deletions(-) rename src/main/kotlin/io/github/freya022/botcommands/{internal => api}/core/service/annotations/RequiresDefaultInjection.kt (65%) diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/annotations/RequiresDefaultInjection.kt b/src/main/kotlin/io/github/freya022/botcommands/api/core/service/annotations/RequiresDefaultInjection.kt similarity index 65% rename from src/main/kotlin/io/github/freya022/botcommands/internal/core/service/annotations/RequiresDefaultInjection.kt rename to src/main/kotlin/io/github/freya022/botcommands/api/core/service/annotations/RequiresDefaultInjection.kt index 71458a7e7..fa6ca1b77 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/annotations/RequiresDefaultInjection.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/api/core/service/annotations/RequiresDefaultInjection.kt @@ -1,4 +1,4 @@ -package io.github.freya022.botcommands.internal.core.service.annotations +package io.github.freya022.botcommands.api.core.service.annotations import io.github.freya022.botcommands.internal.core.service.BCInjectionCondition import org.springframework.context.annotation.Conditional @@ -7,4 +7,4 @@ import org.springframework.context.annotation.Conditional * Makes a service disabled when using Spring */ @Conditional(BCInjectionCondition::class) -internal annotation class RequiresDefaultInjection +annotation class RequiresDefaultInjection diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCClassAnnotationsMap.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCClassAnnotationsMap.kt index 86384ecd5..49945cf99 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCClassAnnotationsMap.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCClassAnnotationsMap.kt @@ -1,8 +1,8 @@ package io.github.freya022.botcommands.internal.core.service import io.github.freya022.botcommands.api.core.service.annotations.BService +import io.github.freya022.botcommands.api.core.service.annotations.RequiresDefaultInjection import io.github.freya022.botcommands.api.core.service.annotations.ServiceType -import io.github.freya022.botcommands.internal.core.service.annotations.RequiresDefaultInjection import kotlin.reflect.KClass /** diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCInstantiableServices.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCInstantiableServices.kt index 85320da96..3dd08670b 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCInstantiableServices.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCInstantiableServices.kt @@ -3,10 +3,10 @@ package io.github.freya022.botcommands.internal.core.service import io.github.freya022.botcommands.api.core.service.ServiceError import io.github.freya022.botcommands.api.core.service.annotations.BService import io.github.freya022.botcommands.api.core.service.annotations.InterfacedService +import io.github.freya022.botcommands.api.core.service.annotations.RequiresDefaultInjection import io.github.freya022.botcommands.api.core.service.annotations.ServiceType import io.github.freya022.botcommands.api.core.utils.joinAsList import io.github.freya022.botcommands.api.core.utils.simpleNestedName -import io.github.freya022.botcommands.internal.core.service.annotations.RequiresDefaultInjection import io.github.freya022.botcommands.internal.core.service.provider.ServiceProvider import io.github.freya022.botcommands.internal.core.service.provider.ServiceProviders import io.github.freya022.botcommands.internal.utils.reference diff --git a/src/test/java/io/github/freya022/botcommands/test/commands/slash/SlashDIJava.java b/src/test/java/io/github/freya022/botcommands/test/commands/slash/SlashDIJava.java index 1558b71d2..de115bb4d 100644 --- a/src/test/java/io/github/freya022/botcommands/test/commands/slash/SlashDIJava.java +++ b/src/test/java/io/github/freya022/botcommands/test/commands/slash/SlashDIJava.java @@ -7,9 +7,9 @@ import io.github.freya022.botcommands.api.commands.application.slash.annotations.JDASlashCommand; import io.github.freya022.botcommands.api.core.db.BlockingDatabase; import io.github.freya022.botcommands.api.core.service.LazyService; +import io.github.freya022.botcommands.api.core.service.annotations.RequiresDefaultInjection; import io.github.freya022.botcommands.api.core.service.annotations.ServiceName; import io.github.freya022.botcommands.internal.core.ReadyListener; -import io.github.freya022.botcommands.internal.core.service.annotations.RequiresDefaultInjection; import io.github.freya022.botcommands.test.services.INamedService; import io.github.freya022.botcommands.test.services.UnusedInterfacedService; import org.jetbrains.annotations.Nullable; diff --git a/src/test/kotlin/io/github/freya022/botcommands/test/commands/slash/SlashDI.kt b/src/test/kotlin/io/github/freya022/botcommands/test/commands/slash/SlashDI.kt index 049b4c967..212f85c69 100644 --- a/src/test/kotlin/io/github/freya022/botcommands/test/commands/slash/SlashDI.kt +++ b/src/test/kotlin/io/github/freya022/botcommands/test/commands/slash/SlashDI.kt @@ -9,9 +9,9 @@ import io.github.freya022.botcommands.api.commands.application.slash.annotations import io.github.freya022.botcommands.api.core.DefaultEmbedSupplier import io.github.freya022.botcommands.api.core.db.BlockingDatabase import io.github.freya022.botcommands.api.core.service.LazyService +import io.github.freya022.botcommands.api.core.service.annotations.RequiresDefaultInjection import io.github.freya022.botcommands.api.core.service.annotations.ServiceName import io.github.freya022.botcommands.internal.core.ReadyListener -import io.github.freya022.botcommands.internal.core.service.annotations.RequiresDefaultInjection import io.github.freya022.botcommands.test.services.INamedService import io.github.freya022.botcommands.test.services.NamedService1 import io.github.freya022.botcommands.test.services.UnusedInterfacedService diff --git a/src/test/kotlin/io/github/freya022/botcommands/test/commands/slash/SlashDynamicTypedResolver.kt b/src/test/kotlin/io/github/freya022/botcommands/test/commands/slash/SlashDynamicTypedResolver.kt index ad04a80d0..2b641783a 100644 --- a/src/test/kotlin/io/github/freya022/botcommands/test/commands/slash/SlashDynamicTypedResolver.kt +++ b/src/test/kotlin/io/github/freya022/botcommands/test/commands/slash/SlashDynamicTypedResolver.kt @@ -7,7 +7,7 @@ import io.github.freya022.botcommands.api.commands.application.ApplicationComman import io.github.freya022.botcommands.api.commands.application.slash.GuildSlashEvent import io.github.freya022.botcommands.api.commands.application.slash.annotations.JDASlashCommand import io.github.freya022.botcommands.api.commands.application.slash.annotations.SlashOption -import io.github.freya022.botcommands.internal.core.service.annotations.RequiresDefaultInjection +import io.github.freya022.botcommands.api.core.service.annotations.RequiresDefaultInjection @Command // As the framework checks if a custom option isn't in reality a service option, diff --git a/src/test/kotlin/io/github/freya022/botcommands/test/resolvers/MapResolver.kt b/src/test/kotlin/io/github/freya022/botcommands/test/resolvers/MapResolver.kt index 8a17fc430..dc4ac14e1 100644 --- a/src/test/kotlin/io/github/freya022/botcommands/test/resolvers/MapResolver.kt +++ b/src/test/kotlin/io/github/freya022/botcommands/test/resolvers/MapResolver.kt @@ -1,13 +1,13 @@ package io.github.freya022.botcommands.test.resolvers import io.github.freya022.botcommands.api.core.options.Option +import io.github.freya022.botcommands.api.core.service.annotations.RequiresDefaultInjection import io.github.freya022.botcommands.api.core.service.annotations.ResolverFactory import io.github.freya022.botcommands.api.core.utils.simpleNestedName import io.github.freya022.botcommands.api.parameters.ParameterResolverFactory import io.github.freya022.botcommands.api.parameters.ResolverRequest import io.github.freya022.botcommands.api.parameters.TypedParameterResolver import io.github.freya022.botcommands.api.parameters.resolvers.ICustomResolver -import io.github.freya022.botcommands.internal.core.service.annotations.RequiresDefaultInjection import net.dv8tion.jda.api.events.Event import kotlin.reflect.KType import kotlin.reflect.typeOf diff --git a/src/test/kotlin/io/github/freya022/botcommands/test/services/FactoryServiceTest.kt b/src/test/kotlin/io/github/freya022/botcommands/test/services/FactoryServiceTest.kt index d1bd1ca67..00358a668 100644 --- a/src/test/kotlin/io/github/freya022/botcommands/test/services/FactoryServiceTest.kt +++ b/src/test/kotlin/io/github/freya022/botcommands/test/services/FactoryServiceTest.kt @@ -1,10 +1,6 @@ package io.github.freya022.botcommands.test.services -import io.github.freya022.botcommands.api.core.service.annotations.BConfiguration -import io.github.freya022.botcommands.api.core.service.annotations.BService -import io.github.freya022.botcommands.api.core.service.annotations.ConditionalService -import io.github.freya022.botcommands.api.core.service.annotations.Dependencies -import io.github.freya022.botcommands.internal.core.service.annotations.RequiresDefaultInjection +import io.github.freya022.botcommands.api.core.service.annotations.* //Can test failure if FactoryServiceTest is not instantiable, by commenting @Dependencies @BService diff --git a/src/test/kotlin/io/github/freya022/botcommands/test/services/NamedService.kt b/src/test/kotlin/io/github/freya022/botcommands/test/services/NamedService.kt index 158940f1c..ef8be5cdf 100644 --- a/src/test/kotlin/io/github/freya022/botcommands/test/services/NamedService.kt +++ b/src/test/kotlin/io/github/freya022/botcommands/test/services/NamedService.kt @@ -2,7 +2,7 @@ package io.github.freya022.botcommands.test.services import io.github.freya022.botcommands.api.core.service.annotations.BService import io.github.freya022.botcommands.api.core.service.annotations.InterfacedService -import io.github.freya022.botcommands.internal.core.service.annotations.RequiresDefaultInjection +import io.github.freya022.botcommands.api.core.service.annotations.RequiresDefaultInjection @InterfacedService(acceptMultiple = true) interface INamedService diff --git a/src/test/kotlin/io/github/freya022/botcommands/test/services/NonUniqueProviderTest.kt b/src/test/kotlin/io/github/freya022/botcommands/test/services/NonUniqueProviderTest.kt index f84cdf6b7..7aaeaf1f3 100644 --- a/src/test/kotlin/io/github/freya022/botcommands/test/services/NonUniqueProviderTest.kt +++ b/src/test/kotlin/io/github/freya022/botcommands/test/services/NonUniqueProviderTest.kt @@ -3,7 +3,7 @@ package io.github.freya022.botcommands.test.services import io.github.freya022.botcommands.api.core.service.annotations.BConfiguration import io.github.freya022.botcommands.api.core.service.annotations.BService import io.github.freya022.botcommands.api.core.service.annotations.Lazy -import io.github.freya022.botcommands.internal.core.service.annotations.RequiresDefaultInjection +import io.github.freya022.botcommands.api.core.service.annotations.RequiresDefaultInjection // Even if this is lazy, this should throw as Service1 has multiple definitions and no name matches @Lazy From 204b74522d3505f9bfebe1106a4f7264e8f4cf7c Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Sat, 31 May 2025 14:57:13 +0200 Subject: [PATCH 09/45] Update names --- .../core/config/BotCommandsConfigurations.kt | 2 +- .../botcommands/api/core/config/BConfig.kt | 18 +++++++++--------- .../{BHotReloadConfig.kt => BRestartConfig.kt} | 16 ++++++++-------- .../ExperimentalRestartApi.kt} | 6 +++--- .../core/service/BCBotCommandsBootstrap.kt | 2 +- .../freya022/botcommands/reload/test/Main.kt | 8 ++++---- 6 files changed, 26 insertions(+), 26 deletions(-) rename src/main/kotlin/io/github/freya022/botcommands/api/core/config/{BHotReloadConfig.kt => BRestartConfig.kt} (65%) rename src/main/kotlin/io/github/freya022/botcommands/api/{core/reload/ExperimentalHotReloadApi.kt => restart/ExperimentalRestartApi.kt} (75%) diff --git a/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/BotCommandsConfigurations.kt b/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/BotCommandsConfigurations.kt index a211dffd2..c750668bd 100644 --- a/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/BotCommandsConfigurations.kt +++ b/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/BotCommandsConfigurations.kt @@ -43,7 +43,7 @@ internal class BotCommandsCoreConfiguration( override val modalsConfig: Nothing get() = unusable() override val componentsConfig: Nothing get() = unusable() override val coroutineScopesConfig: Nothing get() = unusable() - override val hotReloadConfig: Nothing get() = unusable() + override val restartConfig: Nothing get() = unusable() } internal fun BConfigBuilder.applyConfig(configuration: BotCommandsCoreConfiguration, jdaConfiguration: JDAConfiguration) = apply { diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BConfig.kt b/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BConfig.kt index e741e7c70..08496c507 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BConfig.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BConfig.kt @@ -4,7 +4,6 @@ import io.github.freya022.botcommands.api.ReceiverConsumer import io.github.freya022.botcommands.api.commands.text.annotations.Hidden import io.github.freya022.botcommands.api.core.BotOwners import io.github.freya022.botcommands.api.core.annotations.BEventListener -import io.github.freya022.botcommands.api.core.reload.ExperimentalHotReloadApi import io.github.freya022.botcommands.api.core.requests.PriorityGlobalRestRateLimiter import io.github.freya022.botcommands.api.core.service.ClassGraphProcessor import io.github.freya022.botcommands.api.core.service.annotations.InjectedService @@ -13,6 +12,7 @@ import io.github.freya022.botcommands.api.core.utils.loggerOf import io.github.freya022.botcommands.api.core.utils.toImmutableList import io.github.freya022.botcommands.api.core.utils.toImmutableSet import io.github.freya022.botcommands.api.core.waiter.EventWaiter +import io.github.freya022.botcommands.api.restart.ExperimentalRestartApi import io.github.freya022.botcommands.internal.core.config.ConfigDSL import io.github.freya022.botcommands.internal.core.config.ConfigurationValue import io.github.oshai.kotlinlogging.KotlinLogging @@ -130,7 +130,7 @@ interface BConfig { val modalsConfig: BModalsConfig val componentsConfig: BComponentsConfig val coroutineScopesConfig: BCoroutineScopesConfig - val hotReloadConfig: BHotReloadConfig + val restartConfig: BRestartConfig } @ConfigDSL @@ -168,8 +168,8 @@ class BConfigBuilder : BConfig { override val modalsConfig = BModalsConfigBuilder() override val componentsConfig = BComponentsConfigBuilder() override val coroutineScopesConfig = BCoroutineScopesConfigBuilder() - @ExperimentalHotReloadApi - override val hotReloadConfig = BHotReloadConfigBuilder() + @ExperimentalRestartApi + override val restartConfig = BRestartConfigBuilder() /** * Predefined user IDs of the bot owners, allowing bypassing cooldowns, user permission checks, @@ -295,9 +295,9 @@ class BConfigBuilder : BConfig { componentsConfig.apply(block) } - @ExperimentalHotReloadApi - fun hotReload(block: ReceiverConsumer) { - hotReloadConfig.apply(block) + @ExperimentalRestartApi + fun restart(block: ReceiverConsumer) { + restartConfig.apply(block) } fun build(): BConfig { @@ -327,8 +327,8 @@ class BConfigBuilder : BConfig { override val modalsConfig = this@BConfigBuilder.modalsConfig.build() override val componentsConfig = this@BConfigBuilder.componentsConfig.build() override val coroutineScopesConfig = this@BConfigBuilder.coroutineScopesConfig.build() - @ExperimentalHotReloadApi - override val hotReloadConfig = this@BConfigBuilder.hotReloadConfig.build() + @ExperimentalRestartApi + override val restartConfig = this@BConfigBuilder.restartConfig.build() } } } diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BHotReloadConfig.kt b/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BRestartConfig.kt similarity index 65% rename from src/main/kotlin/io/github/freya022/botcommands/api/core/config/BHotReloadConfig.kt rename to src/main/kotlin/io/github/freya022/botcommands/api/core/config/BRestartConfig.kt index ed5d69b45..67bd38eb6 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BHotReloadConfig.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BRestartConfig.kt @@ -1,14 +1,14 @@ package io.github.freya022.botcommands.api.core.config -import io.github.freya022.botcommands.api.core.reload.ExperimentalHotReloadApi import io.github.freya022.botcommands.api.core.service.annotations.InjectedService import io.github.freya022.botcommands.api.core.utils.toImmutableList +import io.github.freya022.botcommands.api.restart.ExperimentalRestartApi import io.github.freya022.botcommands.internal.core.config.ConfigDSL import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @InjectedService -interface BHotReloadConfig { +interface BRestartConfig { val enable: Boolean @@ -18,9 +18,9 @@ interface BHotReloadConfig { val restartDelay: Duration } -@ExperimentalHotReloadApi +@ExperimentalRestartApi @ConfigDSL -class BHotReloadConfigBuilder : BHotReloadConfig { +class BRestartConfigBuilder : BRestartConfig { // TODO do not make "enable" and "args" separately mutable, add a "enable" method with takes args and sets both // so the user does not get surprises @@ -32,9 +32,9 @@ class BHotReloadConfigBuilder : BHotReloadConfig { override val restartDelay: Duration = 1.seconds - internal fun build() = object : BHotReloadConfig { - override val enable = this@BHotReloadConfigBuilder.enable - override val args = this@BHotReloadConfigBuilder.args.toImmutableList() - override val restartDelay = this@BHotReloadConfigBuilder.restartDelay + internal fun build() = object : BRestartConfig { + override val enable = this@BRestartConfigBuilder.enable + override val args = this@BRestartConfigBuilder.args.toImmutableList() + override val restartDelay = this@BRestartConfigBuilder.restartDelay } } \ No newline at end of file diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/core/reload/ExperimentalHotReloadApi.kt b/src/main/kotlin/io/github/freya022/botcommands/api/restart/ExperimentalRestartApi.kt similarity index 75% rename from src/main/kotlin/io/github/freya022/botcommands/api/core/reload/ExperimentalHotReloadApi.kt rename to src/main/kotlin/io/github/freya022/botcommands/api/restart/ExperimentalRestartApi.kt index d717084dd..1cad6b510 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/api/core/reload/ExperimentalHotReloadApi.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/api/restart/ExperimentalRestartApi.kt @@ -1,4 +1,4 @@ -package io.github.freya022.botcommands.api.core.reload +package io.github.freya022.botcommands.api.restart /** * Opt-in marker annotation for the hot reloading feature. @@ -8,10 +8,10 @@ package io.github.freya022.botcommands.api.core.reload * Please create an issue if you encounter a problem, including if it needs adaptations for your use case. */ @RequiresOptIn( - message = "This feature is experimental, please see the documentation of this opt-in annotation (@ExperimentalHotReloadApi) for more details.", + message = "This feature is experimental, please see the documentation of this opt-in annotation (@ExperimentalRestartApi) for more details.", level = RequiresOptIn.Level.ERROR ) @Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY, AnnotationTarget.PROPERTY_SETTER) @Retention(AnnotationRetention.BINARY) @MustBeDocumented -annotation class ExperimentalHotReloadApi +annotation class ExperimentalRestartApi diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCBotCommandsBootstrap.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCBotCommandsBootstrap.kt index 1fb878422..40d937b5a 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCBotCommandsBootstrap.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCBotCommandsBootstrap.kt @@ -46,7 +46,7 @@ internal class BCBotCommandsBootstrap internal constructor( serviceContainer.putServiceAs(config.componentsConfig) serviceContainer.putServiceAs(config.coroutineScopesConfig) serviceContainer.putServiceAs(config.textConfig) - serviceContainer.putServiceAs(config.hotReloadConfig) + serviceContainer.putServiceAs(config.restartConfig) } // This does not use the usual event dispatcher to avoid more delays diff --git a/src/test/kotlin/io/github/freya022/botcommands/reload/test/Main.kt b/src/test/kotlin/io/github/freya022/botcommands/reload/test/Main.kt index 54ed99d76..267e306c7 100644 --- a/src/test/kotlin/io/github/freya022/botcommands/reload/test/Main.kt +++ b/src/test/kotlin/io/github/freya022/botcommands/reload/test/Main.kt @@ -4,7 +4,7 @@ import ch.qos.logback.classic.ClassicConstants import dev.freya02.botcommands.internal.restart.Restarter import io.github.freya022.botcommands.api.core.BotCommands import io.github.freya022.botcommands.api.core.config.DevConfig -import io.github.freya022.botcommands.api.core.reload.ExperimentalHotReloadApi +import io.github.freya022.botcommands.api.restart.ExperimentalRestartApi import io.github.freya022.botcommands.test.config.Environment import io.github.oshai.kotlinlogging.KotlinLogging import java.lang.management.ManagementFactory @@ -13,7 +13,7 @@ import kotlin.io.path.absolutePathString private val logger by lazy { KotlinLogging.logger { } } -@OptIn(ExperimentalHotReloadApi::class) +@OptIn(ExperimentalRestartApi::class) fun main(args: Array) { System.setProperty(ClassicConstants.CONFIG_FILE_PROPERTY, Environment.logbackConfigPath.absolutePathString()) logger.info { "Loading logback configuration at ${Environment.logbackConfigPath.absolutePathString()}" } @@ -53,8 +53,8 @@ fun main(args: Array) { enable = false } - @OptIn(ExperimentalHotReloadApi::class) - hotReload { + @OptIn(ExperimentalRestartApi::class) + restart { enable = true this.args = args.toList() From a9f23d483d7b8b51b14c0f1f10c9fae91cf87771 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Sat, 31 May 2025 17:19:28 +0200 Subject: [PATCH 10/45] Move main args to BConfig through entry point, leave feature enabled As it requires adding a dependency --- .../core/config/BotCommandsConfigurations.kt | 2 + .../internal/core/config/ConfigProvider.kt | 4 +- .../kotlin/io/github/freya022/bot/Main.kt | 4 +- .../botcommands/api/core/BotCommands.kt | 37 ++++++++++++++++++- .../botcommands/api/core/config/BConfig.kt | 15 +++++++- .../api/core/config/BRestartConfig.kt | 16 -------- .../botcommands/framework/utils/Utils.kt | 2 +- .../freya022/botcommands/reload/test/Main.kt | 11 +----- .../github/freya022/botcommands/test/Main.kt | 2 +- 9 files changed, 59 insertions(+), 34 deletions(-) diff --git a/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/BotCommandsConfigurations.kt b/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/BotCommandsConfigurations.kt index c750668bd..adc8457cd 100644 --- a/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/BotCommandsConfigurations.kt +++ b/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/BotCommandsConfigurations.kt @@ -32,6 +32,7 @@ internal class BotCommandsCoreConfiguration( override val shutdownTimeout = shutdownTimeout.toKotlinDuration() + override val args: Nothing get() = unusable() override val beforeStart: Nothing get() = unusable() override val classGraphProcessors: Nothing get() = unusable() override val serviceConfig: Nothing get() = unusable() @@ -47,6 +48,7 @@ internal class BotCommandsCoreConfiguration( } internal fun BConfigBuilder.applyConfig(configuration: BotCommandsCoreConfiguration, jdaConfiguration: JDAConfiguration) = apply { + // args is assigned in builder predefinedOwnerIds += configuration.predefinedOwnerIds packages += configuration.packages classes += configuration.classes diff --git a/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/ConfigProvider.kt b/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/ConfigProvider.kt index 8959c1693..7c48339dd 100644 --- a/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/ConfigProvider.kt +++ b/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/ConfigProvider.kt @@ -1,6 +1,7 @@ package io.github.freya022.botcommands.internal.core.config import io.github.freya022.botcommands.api.core.config.* +import org.springframework.boot.ApplicationArguments import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Primary @@ -10,6 +11,7 @@ internal open class ConfigProvider { @Bean @Primary internal open fun bConfig( + applicationArguments: ApplicationArguments, coreConfiguration: BotCommandsCoreConfiguration, coreConfigurers: List, jdaConfiguration: JDAConfiguration, databaseConfiguration: BotCommandsDatabaseConfiguration, databaseConfigurers: List, appEmojisConfiguration: BotCommandsAppEmojisConfiguration, appEmojisConfigurers: List, @@ -20,7 +22,7 @@ internal open class ConfigProvider { componentsConfiguration: BotCommandsComponentsConfiguration, componentsConfigurers: List, coroutineConfigurers: List ): BConfig = - BConfigBuilder() + BConfigBuilder(applicationArguments.sourceArgs.toList()) .applyConfig(coreConfiguration, jdaConfiguration) .apply { databaseConfig.applyConfig(databaseConfiguration).configure(databaseConfigurers) diff --git a/src/examples/kotlin/io/github/freya022/bot/Main.kt b/src/examples/kotlin/io/github/freya022/bot/Main.kt index eaee484f0..0ce971693 100644 --- a/src/examples/kotlin/io/github/freya022/bot/Main.kt +++ b/src/examples/kotlin/io/github/freya022/bot/Main.kt @@ -1,5 +1,6 @@ package io.github.freya022.bot +import ch.qos.logback.classic.ClassicConstants as LogbackConstants import dev.reformator.stacktracedecoroutinator.jvm.DecoroutinatorJvmApi import io.github.freya022.bot.config.Config import io.github.freya022.bot.config.Environment @@ -10,7 +11,6 @@ import net.dv8tion.jda.api.interactions.DiscordLocale import java.lang.management.ManagementFactory import kotlin.io.path.absolutePathString import kotlin.system.exitProcess -import ch.qos.logback.classic.ClassicConstants as LogbackConstants private val logger by lazy { KotlinLogging.logger {} } // Must not load before system property is set @@ -39,7 +39,7 @@ object Main { val config = Config.instance - BotCommands.create { + BotCommands.create(args) { disableExceptionsInDMs = Environment.isDev addPredefinedOwners(*config.ownerIds.toLongArray()) diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/core/BotCommands.kt b/src/main/kotlin/io/github/freya022/botcommands/api/core/BotCommands.kt index 8d22a7d4c..791866e07 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/api/core/BotCommands.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/api/core/BotCommands.kt @@ -40,8 +40,9 @@ object BotCommands { */ @JvmStatic @JvmName("create") + @Deprecated(message = "Replaced with create(String, ReceiverConsumer)", ReplaceWith("create(args, configConsumer)")) fun createJava(configConsumer: ReceiverConsumer): BContext { - return create(configConsumer = configConsumer) + return create(emptyArray(), configConsumer = configConsumer) } /** @@ -55,8 +56,40 @@ object BotCommands { * @see BotCommands */ @JvmSynthetic + @Deprecated(message = "Replaced with create(String, ReceiverConsumer)", ReplaceWith("create(args, configConsumer)")) fun create(configConsumer: BConfigBuilder.() -> Unit): BContext { - return build(BConfigBuilder().apply(configConsumer).build()) + return build(BConfigBuilder(emptyList()).apply(configConsumer).build()) + } + + /** + * Creates a new instance of the framework. + * + * @return The context for the newly created framework instance, + * while this is returned, using it *usually* is not a good idea, + * your architecture should rely on [dependency injection](https://bc.freya02.dev/3.X/using-botcommands/dependency-injection/) + * and events instead. + * + * @see BotCommands + */ + @JvmStatic + @JvmName("create") + fun createJava(args: Array, configConsumer: ReceiverConsumer): BContext { + return create(args, configConsumer = configConsumer) + } + + /** + * Creates a new instance of the framework. + * + * @return The context for the newly created framework instance, + * while this is returned, using it *usually* is not a good idea, + * your architecture should rely on [dependency injection](https://bc.freya02.dev/3.X/using-botcommands/dependency-injection/) + * and events instead. + * + * @see BotCommands + */ + @JvmSynthetic + fun create(args: Array, configConsumer: BConfigBuilder.() -> Unit): BContext { + return build(BConfigBuilder(args.toList()).apply(configConsumer).build()) } private fun build(config: BConfig): BContext { diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BConfig.kt b/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BConfig.kt index 08496c507..e15f28209 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BConfig.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BConfig.kt @@ -2,6 +2,7 @@ package io.github.freya022.botcommands.api.core.config import io.github.freya022.botcommands.api.ReceiverConsumer import io.github.freya022.botcommands.api.commands.text.annotations.Hidden +import io.github.freya022.botcommands.api.core.BotCommands import io.github.freya022.botcommands.api.core.BotOwners import io.github.freya022.botcommands.api.core.annotations.BEventListener import io.github.freya022.botcommands.api.core.requests.PriorityGlobalRestRateLimiter @@ -26,6 +27,15 @@ import kotlin.time.Duration.Companion.seconds @InjectedService interface BConfig { + + /** + * The list of arguments passed to this program's entry point. + * + * This property is supplied by the entry point ([BotCommands] or Spring), + * thus it has no writable property for it. + */ + val args: List + /** * Predefined user IDs of the bot owners, allowing bypassing cooldowns, user permission checks, * and having [hidden commands][Hidden] shown. @@ -134,7 +144,9 @@ interface BConfig { } @ConfigDSL -class BConfigBuilder : BConfig { +class BConfigBuilder( + override val args: List, +) : BConfig { override val packages: MutableSet = HashSet() override val classes: MutableSet> = HashSet() @@ -306,6 +318,7 @@ class BConfigBuilder : BConfig { logger.info { "Disabled sending exception in bot owners DMs" } return object : BConfig { + override val args = this@BConfigBuilder.args.toImmutableList() override val predefinedOwnerIds = this@BConfigBuilder.predefinedOwnerIds.toImmutableSet() override val packages = this@BConfigBuilder.packages.toImmutableSet() override val classes = this@BConfigBuilder.classes.toImmutableSet() diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BRestartConfig.kt b/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BRestartConfig.kt index 67bd38eb6..d4ffa63e9 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BRestartConfig.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BRestartConfig.kt @@ -1,7 +1,6 @@ package io.github.freya022.botcommands.api.core.config import io.github.freya022.botcommands.api.core.service.annotations.InjectedService -import io.github.freya022.botcommands.api.core.utils.toImmutableList import io.github.freya022.botcommands.api.restart.ExperimentalRestartApi import io.github.freya022.botcommands.internal.core.config.ConfigDSL import kotlin.time.Duration @@ -9,11 +8,6 @@ import kotlin.time.Duration.Companion.seconds @InjectedService interface BRestartConfig { - - val enable: Boolean - - val args: List - // TODO java duration val restartDelay: Duration } @@ -22,19 +16,9 @@ interface BRestartConfig { @ConfigDSL class BRestartConfigBuilder : BRestartConfig { - // TODO do not make "enable" and "args" separately mutable, add a "enable" method with takes args and sets both - // so the user does not get surprises - // also add disable() for parity - @set:JvmName("enable") - override var enable: Boolean = false - - override var args: List = emptyList() - override val restartDelay: Duration = 1.seconds internal fun build() = object : BRestartConfig { - override val enable = this@BRestartConfigBuilder.enable - override val args = this@BRestartConfigBuilder.args.toImmutableList() override val restartDelay = this@BRestartConfigBuilder.restartDelay } } \ No newline at end of file diff --git a/src/test/kotlin/io/github/freya022/botcommands/framework/utils/Utils.kt b/src/test/kotlin/io/github/freya022/botcommands/framework/utils/Utils.kt index 34a748aee..56427162c 100644 --- a/src/test/kotlin/io/github/freya022/botcommands/framework/utils/Utils.kt +++ b/src/test/kotlin/io/github/freya022/botcommands/framework/utils/Utils.kt @@ -11,7 +11,7 @@ fun BotCommands.createTest( modals: Boolean = false, appEmojis: Boolean = false, builder: BConfigBuilder.() -> Unit -) = create { +) = create(emptyArray()) { disableExceptionsInDMs = true addClass() diff --git a/src/test/kotlin/io/github/freya022/botcommands/reload/test/Main.kt b/src/test/kotlin/io/github/freya022/botcommands/reload/test/Main.kt index 267e306c7..8cbaefe3c 100644 --- a/src/test/kotlin/io/github/freya022/botcommands/reload/test/Main.kt +++ b/src/test/kotlin/io/github/freya022/botcommands/reload/test/Main.kt @@ -4,7 +4,6 @@ import ch.qos.logback.classic.ClassicConstants import dev.freya02.botcommands.internal.restart.Restarter import io.github.freya022.botcommands.api.core.BotCommands import io.github.freya022.botcommands.api.core.config.DevConfig -import io.github.freya022.botcommands.api.restart.ExperimentalRestartApi import io.github.freya022.botcommands.test.config.Environment import io.github.oshai.kotlinlogging.KotlinLogging import java.lang.management.ManagementFactory @@ -13,7 +12,6 @@ import kotlin.io.path.absolutePathString private val logger by lazy { KotlinLogging.logger { } } -@OptIn(ExperimentalRestartApi::class) fun main(args: Array) { System.setProperty(ClassicConstants.CONFIG_FILE_PROPERTY, Environment.logbackConfigPath.absolutePathString()) logger.info { "Loading logback configuration at ${Environment.logbackConfigPath.absolutePathString()}" } @@ -31,7 +29,7 @@ fun main(args: Array) { // DecoroutinatorJvmApi.install() } - BotCommands.create { + BotCommands.create(args) { disableExceptionsInDMs = true addSearchPath("io.github.freya022.botcommands.reload.test") @@ -52,13 +50,6 @@ fun main(args: Array) { modals { enable = false } - - @OptIn(ExperimentalRestartApi::class) - restart { - enable = true - - this.args = args.toList() - } } thread(isDaemon = true) { diff --git a/src/test/kotlin/io/github/freya022/botcommands/test/Main.kt b/src/test/kotlin/io/github/freya022/botcommands/test/Main.kt index 2ea8e9e9a..b53b6eff4 100644 --- a/src/test/kotlin/io/github/freya022/botcommands/test/Main.kt +++ b/src/test/kotlin/io/github/freya022/botcommands/test/Main.kt @@ -37,7 +37,7 @@ object Main { DecoroutinatorJvmApi.install() } - val ctx = BotCommands.create { + val ctx = BotCommands.create(args) { disableExceptionsInDMs = true addSearchPath("io.github.freya022.botcommands.test") From 639a4adf0e3666679c5d21ec9dd01b7722916ba6 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Sat, 31 May 2025 17:53:04 +0200 Subject: [PATCH 11/45] Use service-loaded configurer to override ClassGraph's class loader --- .../internal/core/ClassGraphConfigurer.kt | 10 ++++++++ .../internal/utils/ReflectionMetadata.kt | 23 ++++++++----------- 2 files changed, 20 insertions(+), 13 deletions(-) create mode 100644 src/main/kotlin/io/github/freya022/botcommands/internal/core/ClassGraphConfigurer.kt diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/ClassGraphConfigurer.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/ClassGraphConfigurer.kt new file mode 100644 index 000000000..a93ea3a67 --- /dev/null +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/ClassGraphConfigurer.kt @@ -0,0 +1,10 @@ +package io.github.freya022.botcommands.internal.core + +import io.github.classgraph.ClassGraph +import io.github.freya022.botcommands.api.core.config.BConfig + +interface ClassGraphConfigurer { + fun ClassGraph.configure(arguments: Arguments) + + class Arguments(val config: BConfig) +} \ No newline at end of file diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/utils/ReflectionMetadata.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/utils/ReflectionMetadata.kt index d483e1090..e66684c11 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/utils/ReflectionMetadata.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/utils/ReflectionMetadata.kt @@ -12,6 +12,7 @@ import io.github.freya022.botcommands.api.core.service.annotations.Condition import io.github.freya022.botcommands.api.core.traceNull import io.github.freya022.botcommands.api.core.utils.* import io.github.freya022.botcommands.internal.commands.CommandsPresenceChecker +import io.github.freya022.botcommands.internal.core.ClassGraphConfigurer import io.github.freya022.botcommands.internal.core.HandlersPresenceChecker import io.github.freya022.botcommands.internal.core.service.BotCommandsBootstrap import io.github.freya022.botcommands.internal.emojis.AppEmojiContainerProcessor @@ -21,6 +22,7 @@ import io.github.freya022.botcommands.internal.utils.ReflectionMetadata.MethodMe import io.github.freya022.botcommands.internal.utils.ReflectionUtils.function import io.github.oshai.kotlinlogging.KotlinLogging import java.lang.reflect.Executable +import java.util.* import kotlin.coroutines.Continuation import kotlin.reflect.KClass import kotlin.reflect.KFunction @@ -103,19 +105,14 @@ private class ReflectionMetadataScanner private constructor( logger.debug { "Scanning classes: ${classes.joinToString { it.simpleNestedName }}" } ClassGraph() -// .let { -// val thread = Thread.currentThread() -// if (thread.name != Restarter.RESTARTED_THREAD_NAME) return@let it -// -// val classLoader = thread.contextClassLoader -// // Cannot compare using `is` -// // as HotReloadClassLoader::class would be from the current classloader, -// // while the context class loader is loaded from the app's class loader -// if (classLoader.javaClass.name != HotReloadClassLoader::class.java.name) return@let it -// -// // [[HotReloadClassLoader]] will read from its files first then delegate to the app class loader anyway -// it.overrideClassLoaders(classLoader) -// } + .apply { + val loader = ServiceLoader.load(ClassGraphConfigurer::class.java, ClassLoader.getSystemClassLoader()) + loader.forEach { configurer -> + with(configurer) { + configure(ClassGraphConfigurer.Arguments(config)) + } + } + } .acceptPackages( "io.github.freya022.botcommands.api", "io.github.freya022.botcommands.internal", From 9ae0b974a0bfe46ea2dc440686d44544d2d4188e Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Sat, 31 May 2025 17:53:39 +0200 Subject: [PATCH 12/45] Remove restart test files --- .../freya022/botcommands/reload/test/Bot.kt | 22 ------- .../freya022/botcommands/reload/test/Main.kt | 62 ------------------- 2 files changed, 84 deletions(-) delete mode 100644 src/test/kotlin/io/github/freya022/botcommands/reload/test/Bot.kt delete mode 100644 src/test/kotlin/io/github/freya022/botcommands/reload/test/Main.kt diff --git a/src/test/kotlin/io/github/freya022/botcommands/reload/test/Bot.kt b/src/test/kotlin/io/github/freya022/botcommands/reload/test/Bot.kt deleted file mode 100644 index 32d4b7bb8..000000000 --- a/src/test/kotlin/io/github/freya022/botcommands/reload/test/Bot.kt +++ /dev/null @@ -1,22 +0,0 @@ -package io.github.freya022.botcommands.reload.test - -import io.github.freya022.botcommands.api.core.JDAService -import io.github.freya022.botcommands.api.core.events.BReadyEvent -import io.github.freya022.botcommands.api.core.service.annotations.BService -import net.dv8tion.jda.api.hooks.IEventManager -import net.dv8tion.jda.api.requests.GatewayIntent -import net.dv8tion.jda.api.utils.cache.CacheFlag - -@BService -class Bot : JDAService() { - - override val intents: Set = emptySet() - override val cacheFlags: Set = emptySet() - - override fun createJDA( - event: BReadyEvent, - eventManager: IEventManager - ) { - println("JDA") - } -} \ No newline at end of file diff --git a/src/test/kotlin/io/github/freya022/botcommands/reload/test/Main.kt b/src/test/kotlin/io/github/freya022/botcommands/reload/test/Main.kt deleted file mode 100644 index 8cbaefe3c..000000000 --- a/src/test/kotlin/io/github/freya022/botcommands/reload/test/Main.kt +++ /dev/null @@ -1,62 +0,0 @@ -package io.github.freya022.botcommands.reload.test - -import ch.qos.logback.classic.ClassicConstants -import dev.freya02.botcommands.internal.restart.Restarter -import io.github.freya022.botcommands.api.core.BotCommands -import io.github.freya022.botcommands.api.core.config.DevConfig -import io.github.freya022.botcommands.test.config.Environment -import io.github.oshai.kotlinlogging.KotlinLogging -import java.lang.management.ManagementFactory -import kotlin.concurrent.thread -import kotlin.io.path.absolutePathString - -private val logger by lazy { KotlinLogging.logger { } } - -fun main(args: Array) { - System.setProperty(ClassicConstants.CONFIG_FILE_PROPERTY, Environment.logbackConfigPath.absolutePathString()) - logger.info { "Loading logback configuration at ${Environment.logbackConfigPath.absolutePathString()}" } - - // I use hotswap agent to update my code without restarting the bot - // Of course this only supports modifying existing code - // Refer to https://github.com/HotswapProjects/HotswapAgent#readme on how to use hotswap - - // stacktrace-decoroutinator has issues when reloading with hotswap agent - if ("-XX:+AllowEnhancedClassRedefinition" in ManagementFactory.getRuntimeMXBean().inputArguments) { - logger.info { "Skipping stacktrace-decoroutinator as enhanced hotswap is active" } - } else if ("--no-decoroutinator" in args) { - logger.info { "Skipping stacktrace-decoroutinator as --no-decoroutinator is specified" } - } else { -// DecoroutinatorJvmApi.install() - } - - BotCommands.create(args) { - disableExceptionsInDMs = true - - addSearchPath("io.github.freya022.botcommands.reload.test") - - textCommands { - enable = false - } - - applicationCommands { - enable = false - - databaseCache { - @OptIn(DevConfig::class) - checkOnline = true - } - } - - modals { - enable = false - } - } - - thread(isDaemon = true) { - println("Reading input") - - val line = readln() - if (line == "restart") - Restarter.instance.restart() - } -} \ No newline at end of file From 6db585d922ef52a81eb12cf4b322a7029f3560c4 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Sat, 31 May 2025 17:54:03 +0200 Subject: [PATCH 13/45] Remove try/catch in test file So it doesn't catch the (expected) restart exception --- .../github/freya022/botcommands/test/Main.kt | 102 ++++++++---------- 1 file changed, 44 insertions(+), 58 deletions(-) diff --git a/src/test/kotlin/io/github/freya022/botcommands/test/Main.kt b/src/test/kotlin/io/github/freya022/botcommands/test/Main.kt index b53b6eff4..1e6e2cbe5 100644 --- a/src/test/kotlin/io/github/freya022/botcommands/test/Main.kt +++ b/src/test/kotlin/io/github/freya022/botcommands/test/Main.kt @@ -1,16 +1,13 @@ package io.github.freya022.botcommands.test import ch.qos.logback.classic.ClassicConstants -import dev.reformator.stacktracedecoroutinator.jvm.DecoroutinatorJvmApi import io.github.freya022.botcommands.api.core.BotCommands import io.github.freya022.botcommands.api.core.config.DevConfig import io.github.freya022.botcommands.test.config.Environment import io.github.oshai.kotlinlogging.KotlinLogging import net.dv8tion.jda.api.interactions.DiscordLocale import java.lang.management.ManagementFactory -import kotlin.concurrent.thread import kotlin.io.path.absolutePathString -import kotlin.system.exitProcess import kotlin.time.Duration.Companion.milliseconds const val botName = "BC Test" @@ -20,77 +17,66 @@ object Main { @JvmStatic fun main(args: Array) { - try { - System.setProperty(ClassicConstants.CONFIG_FILE_PROPERTY, Environment.logbackConfigPath.absolutePathString()) - logger.info { "Loading logback configuration at ${Environment.logbackConfigPath.absolutePathString()}" } - - // I use hotswap agent to update my code without restarting the bot - // Of course this only supports modifying existing code - // Refer to https://github.com/HotswapProjects/HotswapAgent#readme on how to use hotswap - - // stacktrace-decoroutinator has issues when reloading with hotswap agent - if ("-XX:+AllowEnhancedClassRedefinition" in ManagementFactory.getRuntimeMXBean().inputArguments) { - logger.info { "Skipping stacktrace-decoroutinator as enhanced hotswap is active" } - } else if ("--no-decoroutinator" in args) { - logger.info { "Skipping stacktrace-decoroutinator as --no-decoroutinator is specified" } - } else { - DecoroutinatorJvmApi.install() - } - - val ctx = BotCommands.create(args) { - disableExceptionsInDMs = true + System.setProperty(ClassicConstants.CONFIG_FILE_PROPERTY, Environment.logbackConfigPath.absolutePathString()) + logger.info { "Loading logback configuration at ${Environment.logbackConfigPath.absolutePathString()}" } + + // I use hotswap agent to update my code without restarting the bot + // Of course this only supports modifying existing code + // Refer to https://github.com/HotswapProjects/HotswapAgent#readme on how to use hotswap + + // stacktrace-decoroutinator has issues when reloading with hotswap agent + if ("-XX:+AllowEnhancedClassRedefinition" in ManagementFactory.getRuntimeMXBean().inputArguments) { + logger.info { "Skipping stacktrace-decoroutinator as enhanced hotswap is active" } + } else if ("--no-decoroutinator" in args) { + logger.info { "Skipping stacktrace-decoroutinator as --no-decoroutinator is specified" } + } else { +// DecoroutinatorJvmApi.install() + } - addSearchPath("io.github.freya022.botcommands.test") + BotCommands.create(args) { + disableExceptionsInDMs = true - database { - queryLogThreshold = 250.milliseconds + addSearchPath("io.github.freya022.botcommands.test") - @OptIn(DevConfig::class) - dumpLongTransactions = true - } + database { + queryLogThreshold = 250.milliseconds - localization { - responseBundles += "Test" - } + @OptIn(DevConfig::class) + dumpLongTransactions = true + } - components { - enable = true - } + localization { + responseBundles += "Test" + } - textCommands { - enable = true + components { + enable = true + } - usePingAsPrefix = true - } + textCommands { + enable = true - services { - debug = false - } + usePingAsPrefix = true + } - applicationCommands { - enable = true + services { + debug = false + } - databaseCache { - @OptIn(DevConfig::class) - checkOnline = true - } + applicationCommands { + enable = true - addLocalizations("MyCommands", DiscordLocale.ENGLISH_US, DiscordLocale.ENGLISH_UK, DiscordLocale.FRENCH) + databaseCache { + @OptIn(DevConfig::class) + checkOnline = true } - modals { - enable = true - } + addLocalizations("MyCommands", DiscordLocale.ENGLISH_US, DiscordLocale.ENGLISH_UK, DiscordLocale.FRENCH) } - thread(isDaemon = true) { - val line = readln() - if (line == "exit") - exitProcess(0) + modals { + enable = true } - } catch (e: Exception) { - logger.error(e) { "Could not start the test bot" } - exitProcess(1) } } } From 892a34eae1c42a01b44a7f6275fd0d7f2dcaaaaa Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Sat, 31 May 2025 18:14:40 +0200 Subject: [PATCH 14/45] Remove BConfig#beforeStart --- .../internal/core/config/BotCommandsConfigurations.kt | 2 +- .../io/github/freya022/botcommands/api/core/BotCommands.kt | 2 -- .../github/freya022/botcommands/api/core/config/BConfig.kt | 6 ------ 3 files changed, 1 insertion(+), 9 deletions(-) diff --git a/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/BotCommandsConfigurations.kt b/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/BotCommandsConfigurations.kt index adc8457cd..7466d07b5 100644 --- a/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/BotCommandsConfigurations.kt +++ b/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/BotCommandsConfigurations.kt @@ -33,7 +33,6 @@ internal class BotCommandsCoreConfiguration( override val shutdownTimeout = shutdownTimeout.toKotlinDuration() override val args: Nothing get() = unusable() - override val beforeStart: Nothing get() = unusable() override val classGraphProcessors: Nothing get() = unusable() override val serviceConfig: Nothing get() = unusable() override val databaseConfig: Nothing get() = unusable() @@ -58,6 +57,7 @@ internal fun BConfigBuilder.applyConfig(configuration: BotCommandsCoreConfigurat ignoredEventIntents += configuration.ignoredEventIntents ignoreRestRateLimiter = configuration.ignoreRestRateLimiter // If the new property has its default value, try to take the deprecated one + @Suppress("DEPRECATION") shutdownTimeout = configuration.shutdownTimeout.takeIf { it != 10.seconds } ?: jdaConfiguration.devTools.shutdownTimeout } diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/core/BotCommands.kt b/src/main/kotlin/io/github/freya022/botcommands/api/core/BotCommands.kt index 791866e07..7b53a660e 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/api/core/BotCommands.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/api/core/BotCommands.kt @@ -93,8 +93,6 @@ object BotCommands { } private fun build(config: BConfig): BContext { - config.beforeStart?.invoke() - val (context, duration) = measureTimedValue { val bootstrap = BCBotCommandsBootstrap(config) bootstrap.injectServices() diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BConfig.kt b/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BConfig.kt index e15f28209..2e0a05670 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BConfig.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BConfig.kt @@ -120,9 +120,6 @@ interface BConfig { val classGraphProcessors: List - // TODO is this really necessary? - val beforeStart: (() -> Unit)? - @ConfigurationValue("botcommands.core.enableShutdownHook", defaultValue = "false") val enableShutdownHook: Boolean @@ -165,8 +162,6 @@ class BConfigBuilder( override val classGraphProcessors: MutableList = arrayListOf() - override var beforeStart: (() -> Unit)? = null - override var enableShutdownHook: Boolean = true override var shutdownTimeout: Duration = 10.seconds @@ -328,7 +323,6 @@ class BConfigBuilder( override val ignoredEventIntents = this@BConfigBuilder.ignoredEventIntents.toImmutableSet() override val ignoreRestRateLimiter = this@BConfigBuilder.ignoreRestRateLimiter override val classGraphProcessors = this@BConfigBuilder.classGraphProcessors.toImmutableList() - override val beforeStart = this@BConfigBuilder.beforeStart override val enableShutdownHook = this@BConfigBuilder.enableShutdownHook override val shutdownTimeout = this@BConfigBuilder.shutdownTimeout override val serviceConfig = this@BConfigBuilder.serviceConfig.build() From febb1a2381b9ec4b67b9f275d827a8b884d3b6bb Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Sat, 31 May 2025 18:26:47 +0200 Subject: [PATCH 15/45] tests: Add back stacktrace-decoroutinator Maybe it works with the restarter, who knows --- src/test/kotlin/io/github/freya022/botcommands/test/Main.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/kotlin/io/github/freya022/botcommands/test/Main.kt b/src/test/kotlin/io/github/freya022/botcommands/test/Main.kt index 1e6e2cbe5..61da0f5bd 100644 --- a/src/test/kotlin/io/github/freya022/botcommands/test/Main.kt +++ b/src/test/kotlin/io/github/freya022/botcommands/test/Main.kt @@ -1,6 +1,7 @@ package io.github.freya022.botcommands.test import ch.qos.logback.classic.ClassicConstants +import dev.reformator.stacktracedecoroutinator.jvm.DecoroutinatorJvmApi import io.github.freya022.botcommands.api.core.BotCommands import io.github.freya022.botcommands.api.core.config.DevConfig import io.github.freya022.botcommands.test.config.Environment @@ -30,7 +31,7 @@ object Main { } else if ("--no-decoroutinator" in args) { logger.info { "Skipping stacktrace-decoroutinator as --no-decoroutinator is specified" } } else { -// DecoroutinatorJvmApi.install() + DecoroutinatorJvmApi.install() } BotCommands.create(args) { From 58e886830621f2726b94508bf9b71ef1eba27f4a Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Sat, 31 May 2025 18:33:32 +0200 Subject: [PATCH 16/45] Add notes --- .../github/freya022/botcommands/internal/core/BContextImpl.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt index a7e4a5a4a..13c018ce3 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt @@ -222,6 +222,7 @@ internal class BContextImpl internal constructor( statusLock.withLock { while (status != Status.SHUTDOWN) { + // TODO seems to wait until the timeout if (!statusCondition.await(durationUntilDeadline().inWholeMilliseconds, TimeUnit.MILLISECONDS)) { return false } @@ -312,5 +313,6 @@ internal class BContextImpl internal constructor( } } + // TODO fails with spring private val jdaOrNull: JDA? get() = getServiceOrNull() } From baf8c0849204cb4dd8eb6e46603500b5c98ce0d3 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Sat, 31 May 2025 19:33:06 +0200 Subject: [PATCH 17/45] Fix name of `JDAConfiguration.shutdownTimeout` replacement property --- .../freya022/botcommands/api/core/config/JDAConfiguration.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/api/core/config/JDAConfiguration.kt b/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/api/core/config/JDAConfiguration.kt index 0fa5e8501..8a0d92d47 100644 --- a/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/api/core/config/JDAConfiguration.kt +++ b/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/api/core/config/JDAConfiguration.kt @@ -61,8 +61,8 @@ class JDAConfiguration internal constructor( * Time to wait until JDA needs to be forcefully shut down, * in other words, this is the allowed time for a graceful shutdown. */ - @Deprecated("Replaced with botcommands.core.jdaShutdownTimeout") - @DeprecatedValue("Replaced with botcommands.core.jdaShutdownTimeout", replacement = "botcommands.core.jdaShutdownTimeout") + @Deprecated("Replaced with botcommands.core.shutdownTimeout") + @DeprecatedValue("Replaced with botcommands.core.shutdownTimeout", replacement = "botcommands.core.shutdownTimeout") @ConfigurationValue("jda.devtools.shutdownTimeout", type = "java.time.Duration", defaultValue = "10s") val shutdownTimeout: Duration = shutdownTimeout.toKotlinDuration() } From 234f108f917c5a9a8f3c80889017148d0d145a0c Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Tue, 3 Jun 2025 23:23:05 +0200 Subject: [PATCH 18/45] Add BRestartConfig#cacheKey --- .../freya022/botcommands/api/core/config/BRestartConfig.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BRestartConfig.kt b/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BRestartConfig.kt index d4ffa63e9..966e2c623 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BRestartConfig.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BRestartConfig.kt @@ -8,6 +8,8 @@ import kotlin.time.Duration.Companion.seconds @InjectedService interface BRestartConfig { + val cacheKey: String? + // TODO java duration val restartDelay: Duration } @@ -16,9 +18,12 @@ interface BRestartConfig { @ConfigDSL class BRestartConfigBuilder : BRestartConfig { - override val restartDelay: Duration = 1.seconds + override var cacheKey: String? = null + + override var restartDelay: Duration = 1.seconds internal fun build() = object : BRestartConfig { + override val cacheKey = this@BRestartConfigBuilder.cacheKey override val restartDelay = this@BRestartConfigBuilder.restartDelay } } \ No newline at end of file From 8858302b8896652bbc917ed26c518c037ce6738a Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Tue, 3 Jun 2025 23:30:25 +0200 Subject: [PATCH 19/45] Add BContext#restartConfig --- .../kotlin/io/github/freya022/botcommands/api/core/BContext.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/core/BContext.kt b/src/main/kotlin/io/github/freya022/botcommands/api/core/BContext.kt index edfba95bd..a20ae81ef 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/api/core/BContext.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/api/core/BContext.kt @@ -69,6 +69,8 @@ interface BContext { get() = config.appEmojisConfig val textConfig: BTextConfig get() = config.textConfig + val restartConfig: BRestartConfig + get() = config.restartConfig //endregion //region Services From e7fded2887aa32fef3a7dc7f27c34b08c18986b4 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Thu, 5 Jun 2025 22:00:42 +0200 Subject: [PATCH 20/45] Shutdown the bot immediately after receiving a ContextClosedEvent --- .../botcommands/internal/core/SpringJDAShutdownHandler.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/SpringJDAShutdownHandler.kt b/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/SpringJDAShutdownHandler.kt index 70e87760f..60f23a561 100644 --- a/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/SpringJDAShutdownHandler.kt +++ b/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/SpringJDAShutdownHandler.kt @@ -14,7 +14,7 @@ internal class SpringJDAShutdownHandler { @EventListener internal fun onContextClosed(event: ContextClosedEvent) { val context = event.applicationContext.getBean() - context.shutdown() + context.shutdownNow() context.awaitShutdown(context.config.shutdownTimeout) } } \ No newline at end of file From 7f33ff7a10a6cd1a692bb583b95e373fcbfbd27b Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Thu, 5 Jun 2025 23:08:35 +0200 Subject: [PATCH 21/45] Remove ClassGraphConfigurer Will instead make a PR to ClassGraph, overriding class loaders isn't enough --- .../botcommands/internal/core/ClassGraphConfigurer.kt | 10 ---------- .../botcommands/internal/utils/ReflectionMetadata.kt | 10 ---------- 2 files changed, 20 deletions(-) delete mode 100644 src/main/kotlin/io/github/freya022/botcommands/internal/core/ClassGraphConfigurer.kt diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/ClassGraphConfigurer.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/ClassGraphConfigurer.kt deleted file mode 100644 index a93ea3a67..000000000 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/ClassGraphConfigurer.kt +++ /dev/null @@ -1,10 +0,0 @@ -package io.github.freya022.botcommands.internal.core - -import io.github.classgraph.ClassGraph -import io.github.freya022.botcommands.api.core.config.BConfig - -interface ClassGraphConfigurer { - fun ClassGraph.configure(arguments: Arguments) - - class Arguments(val config: BConfig) -} \ No newline at end of file diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/utils/ReflectionMetadata.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/utils/ReflectionMetadata.kt index e66684c11..4f2210a2d 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/utils/ReflectionMetadata.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/utils/ReflectionMetadata.kt @@ -12,7 +12,6 @@ import io.github.freya022.botcommands.api.core.service.annotations.Condition import io.github.freya022.botcommands.api.core.traceNull import io.github.freya022.botcommands.api.core.utils.* import io.github.freya022.botcommands.internal.commands.CommandsPresenceChecker -import io.github.freya022.botcommands.internal.core.ClassGraphConfigurer import io.github.freya022.botcommands.internal.core.HandlersPresenceChecker import io.github.freya022.botcommands.internal.core.service.BotCommandsBootstrap import io.github.freya022.botcommands.internal.emojis.AppEmojiContainerProcessor @@ -22,7 +21,6 @@ import io.github.freya022.botcommands.internal.utils.ReflectionMetadata.MethodMe import io.github.freya022.botcommands.internal.utils.ReflectionUtils.function import io.github.oshai.kotlinlogging.KotlinLogging import java.lang.reflect.Executable -import java.util.* import kotlin.coroutines.Continuation import kotlin.reflect.KClass import kotlin.reflect.KFunction @@ -105,14 +103,6 @@ private class ReflectionMetadataScanner private constructor( logger.debug { "Scanning classes: ${classes.joinToString { it.simpleNestedName }}" } ClassGraph() - .apply { - val loader = ServiceLoader.load(ClassGraphConfigurer::class.java, ClassLoader.getSystemClassLoader()) - loader.forEach { configurer -> - with(configurer) { - configure(ClassGraphConfigurer.Arguments(config)) - } - } - } .acceptPackages( "io.github.freya022.botcommands.api", "io.github.freya022.botcommands.internal", From 42cfb1a9b26a0f5be1a23a5288ec5ccecb9bf7ae Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Thu, 5 Jun 2025 23:09:38 +0200 Subject: [PATCH 22/45] Don't set a shutdown hook when using Spring As it has its own hook already --- .../github/freya022/botcommands/internal/core/BContextImpl.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt index 13c018ce3..883d28dfa 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt @@ -20,6 +20,7 @@ import io.github.freya022.botcommands.api.core.utils.awaitShutdown import io.github.freya022.botcommands.api.core.utils.loggerOf import io.github.freya022.botcommands.internal.commands.application.ApplicationCommandsContextImpl import io.github.freya022.botcommands.internal.commands.text.TextCommandsContextImpl +import io.github.freya022.botcommands.internal.core.service.SpringServiceContainer import io.github.freya022.botcommands.internal.utils.unwrap import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.CoroutineScope @@ -69,6 +70,8 @@ internal class BContextImpl internal constructor( private val statusLock: ReentrantLock = ReentrantLock() private val statusCondition: Condition = statusLock.newCondition() private val shutdownHook: Thread? = when { + // Spring has its own shutdown hook that will fire a shutdown event, we handle that + serviceContainer is SpringServiceContainer -> null config.enableShutdownHook -> Thread(::shutdownNow) else -> null } From cbc4ca7441bd40620af8901c4f78a03c1bf70808 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Thu, 5 Jun 2025 23:11:13 +0200 Subject: [PATCH 23/45] Shutdown executors after all shards are shut down Fixes awaiting termination always waiting until the deadline --- .../botcommands/internal/core/BContextImpl.kt | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt index 883d28dfa..eba1c7bc1 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt @@ -169,19 +169,23 @@ internal class BContextImpl internal constructor( runBlocking { setStatus(Status.SHUTTING_DOWN) } - scheduleShutdownSignal() + scheduleShutdownSignal(afterShutdownSignal = { + shutdownEventManagerScope(now = false) + shutdownCoroutineScopes(now = false) + }) shutdownJDA(now = false) - shutdownEventManagerScope(now = false) - shutdownCoroutineScopes(now = false) } override fun shutdownNow() { shutdown() + scheduleShutdownSignal(afterShutdownSignal = { + shutdownEventManagerScope(now = true) + shutdownCoroutineScopes(now = true) + }) + shutdownJDA(now = true) - shutdownEventManagerScope(now = true) - shutdownCoroutineScopes(now = true) } /** @@ -191,11 +195,18 @@ internal class BContextImpl internal constructor( * meaning no request can go through anymore. * * This does not necessarily mean all activities are stopped outside JDA. + * + * [afterShutdownSignal] will only run once. */ - private fun scheduleShutdownSignal() { + private fun scheduleShutdownSignal(afterShutdownSignal: () -> Unit) { fun signalShutdown() = runBlocking { + statusLock.withLock { + if (status == Status.SHUTDOWN) return@runBlocking afterShutdownSignal() + } setStatus(Status.SHUTDOWN) eventDispatcher.dispatchEvent(BShutdownEvent(this@BContextImpl)) + // Shutdown the pools *after* dispatching + afterShutdownSignal() } val jda = jdaOrNull From 740c87658448ed484ba75dd54761c8ec9fbecd15 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Thu, 5 Jun 2025 23:15:15 +0200 Subject: [PATCH 24/45] Replace `Duration.INFINITE` by `ZERO` when awaiting JDA termination --- .../freya022/botcommands/internal/core/BContextImpl.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt index eba1c7bc1..d580329fd 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt @@ -21,6 +21,7 @@ import io.github.freya022.botcommands.api.core.utils.loggerOf import io.github.freya022.botcommands.internal.commands.application.ApplicationCommandsContextImpl import io.github.freya022.botcommands.internal.commands.text.TextCommandsContextImpl import io.github.freya022.botcommands.internal.core.service.SpringServiceContainer +import io.github.freya022.botcommands.internal.utils.takeIfFinite import io.github.freya022.botcommands.internal.utils.unwrap import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.coroutines.CoroutineScope @@ -247,6 +248,8 @@ internal class BContextImpl internal constructor( } private fun awaitJDAShutdown(durationUntilDeadlineFn: () -> Duration): Boolean { + fun Duration.finiteOrZero() = takeIfFinite() ?: Duration.ZERO + val jda = jdaOrNull if (jda == null) { logger.debug { "Not awaiting JDA shutdown as there is no JDA instance registered" } @@ -255,12 +258,12 @@ internal class BContextImpl internal constructor( val shardManager = jda.shardManager if (shardManager != null) { shardManager.shards.forEach { shard -> - if (!shard.awaitShutdown(durationUntilDeadlineFn())) { + if (!shard.awaitShutdown(durationUntilDeadlineFn().finiteOrZero())) { return false } } } else { - if (!jda.awaitShutdown(durationUntilDeadlineFn())) { + if (!jda.awaitShutdown(durationUntilDeadlineFn().finiteOrZero())) { return false } } From 3b523ae0d1c90a7ef6ea1179b9519402b290acc4 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Thu, 5 Jun 2025 23:16:54 +0200 Subject: [PATCH 25/45] Check for any JDA status beyond `CONNECTING_TO_WEBSOCKET` This is required for the JDA restart cache module, as it will fire the READY status directly --- .../github/freya022/botcommands/internal/core/ReadyListener.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/ReadyListener.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/ReadyListener.kt index e0a13bf85..5826f6398 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/ReadyListener.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/ReadyListener.kt @@ -25,7 +25,8 @@ internal class ReadyListener { @BEventListener(priority = Int.MAX_VALUE, mode = RunMode.BLOCKING) internal suspend fun onConnectEvent(event: StatusChangeEvent, context: BContext) { // At this point, JDA should be usable - if (!connected && event.newStatus == JDA.Status.CONNECTING_TO_WEBSOCKET) { + // Use >= because any event beyond this point is a valid JDA instance + if (!connected && event.newStatus >= JDA.Status.CONNECTING_TO_WEBSOCKET) { lock.withLock { if (connected) return connected = true From ac3b285b67ece77de93dcf3495cdbb80f9d01e8d Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Fri, 6 Jun 2025 20:13:18 +0200 Subject: [PATCH 26/45] Use an impossible deadline when the timeout is negative or infinite --- .../freya022/botcommands/internal/core/BContextImpl.kt | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt index d580329fd..73926191f 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt @@ -229,7 +229,7 @@ internal class BContextImpl internal constructor( } override fun awaitShutdown(timeout: Duration): Boolean { - val deadline = Clock.System.now() + timeout + val deadline = Clock.System.now() + (timeout.takeIfFinite() ?: Duration.INFINITE) fun durationUntilDeadline(): Duration = deadline - Clock.System.now() if (!awaitJDAShutdown(::durationUntilDeadline)) @@ -237,7 +237,6 @@ internal class BContextImpl internal constructor( statusLock.withLock { while (status != Status.SHUTDOWN) { - // TODO seems to wait until the timeout if (!statusCondition.await(durationUntilDeadline().inWholeMilliseconds, TimeUnit.MILLISECONDS)) { return false } @@ -248,8 +247,6 @@ internal class BContextImpl internal constructor( } private fun awaitJDAShutdown(durationUntilDeadlineFn: () -> Duration): Boolean { - fun Duration.finiteOrZero() = takeIfFinite() ?: Duration.ZERO - val jda = jdaOrNull if (jda == null) { logger.debug { "Not awaiting JDA shutdown as there is no JDA instance registered" } @@ -258,12 +255,12 @@ internal class BContextImpl internal constructor( val shardManager = jda.shardManager if (shardManager != null) { shardManager.shards.forEach { shard -> - if (!shard.awaitShutdown(durationUntilDeadlineFn().finiteOrZero())) { + if (!shard.awaitShutdown(durationUntilDeadlineFn())) { return false } } } else { - if (!jda.awaitShutdown(durationUntilDeadlineFn().finiteOrZero())) { + if (!jda.awaitShutdown(durationUntilDeadlineFn())) { return false } } From 3bd7689c6319889b9d6406d194321330e1de4b1e Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Fri, 6 Jun 2025 20:14:30 +0200 Subject: [PATCH 27/45] Don't use shutdown() in shutdownNow() Calling scheduleShutdownSignal more than once is not great --- .../freya022/botcommands/internal/core/BContextImpl.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt index 73926191f..1c2f9fb14 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt @@ -179,7 +179,11 @@ internal class BContextImpl internal constructor( } override fun shutdownNow() { - shutdown() + // Do not call shutdown(), more precisely do not call scheduleShutdownSignal() twice + if (status == Status.SHUTTING_DOWN || status == Status.SHUTDOWN) return + removeShutdownHook() + + runBlocking { setStatus(Status.SHUTTING_DOWN) } scheduleShutdownSignal(afterShutdownSignal = { shutdownEventManagerScope(now = true) From 09883e7fd6998acb076a5e84e80a92e3523bbd24 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Fri, 6 Jun 2025 20:15:16 +0200 Subject: [PATCH 28/45] Run BotOwnersImpl#onInjectedJDA async So it doesn't block the main thread unnecessarily, getting the owners will block until the value is set anyway --- .../github/freya022/botcommands/internal/core/BotOwnersImpl.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/BotOwnersImpl.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/BotOwnersImpl.kt index 302d9df3d..9faf841a4 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/BotOwnersImpl.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/BotOwnersImpl.kt @@ -44,7 +44,7 @@ internal class BotOwnersImpl internal constructor( override fun isOwner(user: UserSnowflake): Boolean = user.idLong in owners - @BEventListener + @BEventListener(mode = BEventListener.RunMode.ASYNC) internal suspend fun onInjectedJDA(event: InjectedJDAEvent) { if (ownerWriter.isInitialized()) return From d46deebcfaece0a7300f5c6985ddbfc8bc224633 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Sun, 22 Jun 2025 18:55:25 +0200 Subject: [PATCH 29/45] Move conditional shutdown hook to a service --- .../botcommands/internal/core/BContextImpl.kt | 25 +-------- .../core/service/DefaultShutdownHook.kt | 51 +++++++++++++++++++ 2 files changed, 53 insertions(+), 23 deletions(-) create mode 100644 src/main/kotlin/io/github/freya022/botcommands/internal/core/service/DefaultShutdownHook.kt diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt index 1c2f9fb14..5f683804d 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt @@ -20,7 +20,6 @@ import io.github.freya022.botcommands.api.core.utils.awaitShutdown import io.github.freya022.botcommands.api.core.utils.loggerOf import io.github.freya022.botcommands.internal.commands.application.ApplicationCommandsContextImpl import io.github.freya022.botcommands.internal.commands.text.TextCommandsContextImpl -import io.github.freya022.botcommands.internal.core.service.SpringServiceContainer import io.github.freya022.botcommands.internal.utils.takeIfFinite import io.github.freya022.botcommands.internal.utils.unwrap import io.github.oshai.kotlinlogging.KotlinLogging @@ -70,16 +69,6 @@ internal class BContextImpl internal constructor( private val statusLock: ReentrantLock = ReentrantLock() private val statusCondition: Condition = statusLock.newCondition() - private val shutdownHook: Thread? = when { - // Spring has its own shutdown hook that will fire a shutdown event, we handle that - serviceContainer is SpringServiceContainer -> null - config.enableShutdownHook -> Thread(::shutdownNow) - else -> null - } - - init { - shutdownHook?.let { Runtime.getRuntime().addShutdownHook(it) } - } override fun dispatchException(message: String, t: Throwable?, extraContext: Map) { if (config.disableExceptionsInDMs) return //Don't send DM exceptions in dev mode @@ -166,8 +155,8 @@ internal class BContextImpl internal constructor( override fun shutdown() { if (status == Status.SHUTTING_DOWN || status == Status.SHUTDOWN) return - removeShutdownHook() + // Shutdown hook will be removed by [[DefaultShutdownHook]] if we use the built-in DI runBlocking { setStatus(Status.SHUTTING_DOWN) } scheduleShutdownSignal(afterShutdownSignal = { @@ -181,8 +170,8 @@ internal class BContextImpl internal constructor( override fun shutdownNow() { // Do not call shutdown(), more precisely do not call scheduleShutdownSignal() twice if (status == Status.SHUTTING_DOWN || status == Status.SHUTDOWN) return - removeShutdownHook() + // Shutdown hook will be removed by [[DefaultShutdownHook]] if we use the built-in DI runBlocking { setStatus(Status.SHUTTING_DOWN) } scheduleShutdownSignal(afterShutdownSignal = { @@ -272,16 +261,6 @@ internal class BContextImpl internal constructor( return true } - private fun removeShutdownHook() { - if (shutdownHook == null) return - - try { - Runtime.getRuntime().removeShutdownHook(shutdownHook) - } catch (_: IllegalStateException) { - - } - } - private fun shutdownJDA(now: Boolean) { val jda = jdaOrNull ?: return logger.debug { "Ignoring JDA shutdown as there is no JDA instance registered" } diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/DefaultShutdownHook.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/DefaultShutdownHook.kt new file mode 100644 index 000000000..16496ee3d --- /dev/null +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/DefaultShutdownHook.kt @@ -0,0 +1,51 @@ +package io.github.freya022.botcommands.internal.core.service + +import io.github.freya022.botcommands.api.core.BContext +import io.github.freya022.botcommands.api.core.annotations.BEventListener +import io.github.freya022.botcommands.api.core.config.BConfig +import io.github.freya022.botcommands.api.core.events.BStatusChangeEvent +import io.github.freya022.botcommands.api.core.events.PostLoadEvent +import io.github.freya022.botcommands.api.core.service.ConditionalServiceChecker +import io.github.freya022.botcommands.api.core.service.ServiceContainer +import io.github.freya022.botcommands.api.core.service.annotations.BService +import io.github.freya022.botcommands.api.core.service.annotations.ConditionalService +import io.github.freya022.botcommands.api.core.service.annotations.Lazy +import io.github.freya022.botcommands.api.core.service.annotations.RequiresDefaultInjection +import io.github.freya022.botcommands.api.core.service.getService + +@Lazy +@BService +@ConditionalService(DefaultShutdownHook.ActivationCondition::class) +@RequiresDefaultInjection +internal class DefaultShutdownHook internal constructor( + context: BContext, +) { + + private val hook = Thread { context.shutdownNow() } + + @BEventListener + internal fun registerShutdownHook(event: PostLoadEvent) { + Runtime.getRuntime().addShutdownHook(hook) + } + + @BEventListener + internal fun onShuttingDown(event: BStatusChangeEvent) { + if (event.newStatus == BContext.Status.SHUTTING_DOWN) { + Runtime.getRuntime().removeShutdownHook(hook) + } + } + + internal object ActivationCondition : ConditionalServiceChecker { + + override fun checkServiceAvailability( + serviceContainer: ServiceContainer, + checkedClass: Class<*> + ): String? { + if (!serviceContainer.getService().enableShutdownHook) { + return "Default shutdown hook is disabled" + } + + return null + } + } +} \ No newline at end of file From 83fa419877bf49ff20cee3926b73fc3996ef223e Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Sun, 22 Jun 2025 20:33:23 +0200 Subject: [PATCH 30/45] Port BotCommands-Restart (repo) --- BotCommands-jda-cache/build.gradle.kts | 48 ++++ .../botcommands/restart/jda/cache/Agent.kt | 18 ++ .../jda/cache/BufferingEventManager.kt | 67 +++++ .../restart/jda/cache/DynamicCall.kt | 10 + .../jda/cache/JDABuilderConfiguration.kt | 130 +++++++++ .../restart/jda/cache/JDABuilderSession.kt | 175 ++++++++++++ .../botcommands/restart/jda/cache/JDACache.kt | 21 ++ .../AbstractClassFileTransformer.kt | 27 ++ .../transformer/BContextImplTransformer.kt | 98 +++++++ .../jda/cache/transformer/ClassDescriptors.kt | 32 +++ .../transformer/ContextualClassTransform.kt | 25 ++ .../transformer/JDABuilderTransformer.kt | 258 ++++++++++++++++++ .../cache/transformer/JDAImplTransformer.kt | 242 ++++++++++++++++ .../transformer/JDAServiceTransformer.kt | 114 ++++++++ .../transformer/utils/CodeBuilderUtils.kt | 87 ++++++ .../cache/transformer/utils/TransformUtils.kt | 42 +++ .../restart/jda/cache/utils/JvmUtils.kt | 10 + .../botcommands/restart/jda/cache/Test.java | 34 +++ .../BContextImplTransformerTest.kt | 15 + .../transformer/JDABuilderTransformerTest.kt | 135 +++++++++ .../transformer/JDAImplTransformerTest.kt | 26 ++ .../transformer/JDAServiceTransformerTest.kt | 87 ++++++ .../transformer/utils/CodeBuilderUtilsTest.kt | 121 ++++++++ .../src/test/resources/logback-test.xml | 14 + BotCommands-restarter/build.gradle.kts | 11 + .../restart/ImmediateRestartException.kt | 29 ++ .../internal/restart/LeakSafeExecutor.kt | 61 +++++ .../internal/restart/RestartClassLoader.kt | 40 +++ .../RestartClassLoaderFull.kt.disabled | 116 ++++++++ .../internal/restart/RestartListener.kt | 5 + .../botcommands/internal/restart/Restarter.kt | 125 +++++++++ .../RestarterApplicationStartListener.kt | 16 ++ .../restart/services/RestarterService.kt | 27 ++ .../restart/sources/SourceDirectories.kt | 48 ++++ .../sources/SourceDirectoriesListener.kt | 7 + .../restart/sources/SourceDirectory.kt | 90 ++++++ .../sources/SourceDirectoryListener.kt | 5 + .../internal/restart/sources/SourceFile.kt | 15 + .../internal/restart/sources/SourceFiles.kt | 16 ++ .../internal/restart/utils/AppClasspath.kt | 18 ++ .../botcommands/internal/restart/utils/NIO.kt | 66 +++++ .../restart/watcher/ClasspathListener.kt | 42 +++ .../restart/watcher/ClasspathWatcher.kt | 182 ++++++++++++ settings.gradle.kts | 2 + 44 files changed, 2757 insertions(+) create mode 100644 BotCommands-jda-cache/build.gradle.kts create mode 100644 BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/Agent.kt create mode 100644 BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/BufferingEventManager.kt create mode 100644 BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/DynamicCall.kt create mode 100644 BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/JDABuilderConfiguration.kt create mode 100644 BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/JDABuilderSession.kt create mode 100644 BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/JDACache.kt create mode 100644 BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/AbstractClassFileTransformer.kt create mode 100644 BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/BContextImplTransformer.kt create mode 100644 BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/ClassDescriptors.kt create mode 100644 BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/ContextualClassTransform.kt create mode 100644 BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDABuilderTransformer.kt create mode 100644 BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDAImplTransformer.kt create mode 100644 BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDAServiceTransformer.kt create mode 100644 BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/utils/CodeBuilderUtils.kt create mode 100644 BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/utils/TransformUtils.kt create mode 100644 BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/utils/JvmUtils.kt create mode 100644 BotCommands-jda-cache/src/test/java/dev/freya02/botcommands/restart/jda/cache/Test.java create mode 100644 BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/BContextImplTransformerTest.kt create mode 100644 BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDABuilderTransformerTest.kt create mode 100644 BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDAImplTransformerTest.kt create mode 100644 BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDAServiceTransformerTest.kt create mode 100644 BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/utils/CodeBuilderUtilsTest.kt create mode 100644 BotCommands-jda-cache/src/test/resources/logback-test.xml create mode 100644 BotCommands-restarter/build.gradle.kts create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/ImmediateRestartException.kt create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/LeakSafeExecutor.kt create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/RestartClassLoader.kt create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/RestartClassLoaderFull.kt.disabled create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/RestartListener.kt create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/Restarter.kt create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/services/RestarterApplicationStartListener.kt create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/services/RestarterService.kt create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceDirectories.kt create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceDirectoriesListener.kt create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceDirectory.kt create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceDirectoryListener.kt create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceFile.kt create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceFiles.kt create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/utils/AppClasspath.kt create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/utils/NIO.kt create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/watcher/ClasspathListener.kt create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/watcher/ClasspathWatcher.kt diff --git a/BotCommands-jda-cache/build.gradle.kts b/BotCommands-jda-cache/build.gradle.kts new file mode 100644 index 000000000..cc14b597c --- /dev/null +++ b/BotCommands-jda-cache/build.gradle.kts @@ -0,0 +1,48 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("BotCommands-conventions") + id("BotCommands-publish-conventions") +} + +dependencies { + api(projects.botCommands) + + // Logging + implementation(libs.kotlin.logging) + + // -------------------- TEST DEPENDENCIES -------------------- + + testImplementation(libs.mockk) + testImplementation(libs.bytebuddy) + testImplementation(libs.logback.classic) +} + +java { + sourceCompatibility = JavaVersion.VERSION_24 + targetCompatibility = JavaVersion.VERSION_24 +} + +kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_24 + + freeCompilerArgs.addAll( + "-Xcontext-parameters", + ) + } +} + +val jar by tasks.getting(Jar::class) { + manifest { + attributes( + "Premain-Class" to "dev.freya02.botcommands.restart.jda.cache.Agent", + ) + } +} + +tasks.withType { + useJUnitPlatform() + + jvmArgs("-javaagent:${jar.archiveFile.get().asFile.absolutePath}") +} diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/Agent.kt b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/Agent.kt new file mode 100644 index 000000000..9f46ef85b --- /dev/null +++ b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/Agent.kt @@ -0,0 +1,18 @@ +package dev.freya02.botcommands.restart.jda.cache + +import dev.freya02.botcommands.restart.jda.cache.transformer.BContextImplTransformer +import dev.freya02.botcommands.restart.jda.cache.transformer.JDABuilderTransformer +import dev.freya02.botcommands.restart.jda.cache.transformer.JDAImplTransformer +import dev.freya02.botcommands.restart.jda.cache.transformer.JDAServiceTransformer +import java.lang.instrument.Instrumentation + +object Agent { + + @JvmStatic + fun premain(agentArgs: String?, inst: Instrumentation) { + inst.addTransformer(JDABuilderTransformer) + inst.addTransformer(JDAServiceTransformer) + inst.addTransformer(BContextImplTransformer) + inst.addTransformer(JDAImplTransformer) + } +} \ No newline at end of file diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/BufferingEventManager.kt b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/BufferingEventManager.kt new file mode 100644 index 000000000..32c9581dd --- /dev/null +++ b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/BufferingEventManager.kt @@ -0,0 +1,67 @@ +package dev.freya02.botcommands.restart.jda.cache + +import net.dv8tion.jda.api.events.GenericEvent +import net.dv8tion.jda.api.hooks.IEventManager +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +internal class BufferingEventManager @DynamicCall constructor( + delegate: IEventManager, +) : IEventManager { + + private val lock = ReentrantLock() + private val eventBuffer: MutableList = arrayListOf() + + private var delegate: IEventManager? = delegate + + internal fun setDelegate(delegate: IEventManager) { + lock.withLock { + check(delegate !is BufferingEventManager) { + "Tried to delegate to a BufferingEventManager!" + } + + this.delegate = delegate + eventBuffer.forEach(::handle) + } + } + + internal fun detach() { + lock.withLock { + delegate = null + } + } + + override fun register(listener: Any) { + lock.withLock { + val delegate = delegate ?: error("Should not happen, implement a listener queue if necessary") + delegate.register(listener) + } + } + + override fun unregister(listener: Any) { + lock.withLock { + val delegate = delegate ?: error("Should not happen, implement a listener queue if necessary") + delegate.unregister(listener) + } + } + + override fun handle(event: GenericEvent) { + val delegate = lock.withLock { + val delegate = delegate + if (delegate == null) { + eventBuffer += event + return + } + delegate + } + + delegate.handle(event) + } + + override fun getRegisteredListeners(): List { + lock.withLock { + val delegate = delegate ?: error("Should not happen, implement a listener queue if necessary") + return delegate.registeredListeners + } + } +} \ No newline at end of file diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/DynamicCall.kt b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/DynamicCall.kt new file mode 100644 index 000000000..8c393a4c7 --- /dev/null +++ b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/DynamicCall.kt @@ -0,0 +1,10 @@ +package dev.freya02.botcommands.restart.jda.cache + +/** + * This member is used by generated code and as such is not directly referenced. + * + * This member must be `public`. + */ +@Retention(AnnotationRetention.SOURCE) +@Target(AnnotationTarget.CONSTRUCTOR, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER, AnnotationTarget.PROPERTY_SETTER) +internal annotation class DynamicCall diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/JDABuilderConfiguration.kt b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/JDABuilderConfiguration.kt new file mode 100644 index 000000000..9910c7f40 --- /dev/null +++ b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/JDABuilderConfiguration.kt @@ -0,0 +1,130 @@ +package dev.freya02.botcommands.restart.jda.cache + +import io.github.freya022.botcommands.api.core.utils.enumSetOf +import io.github.freya022.botcommands.api.core.utils.enumSetOfAll +import io.github.oshai.kotlinlogging.KotlinLogging +import net.dv8tion.jda.api.OnlineStatus +import net.dv8tion.jda.api.entities.Activity +import net.dv8tion.jda.api.hooks.IEventManager +import net.dv8tion.jda.api.hooks.InterfacedEventManager +import net.dv8tion.jda.api.utils.ChunkingFilter +import net.dv8tion.jda.api.utils.MemberCachePolicy +import net.dv8tion.jda.api.utils.cache.CacheFlag +import java.util.* + +private val logger = KotlinLogging.logger { } + +class JDABuilderConfiguration internal constructor() { + + private val warnedUnsupportedValues: MutableSet = hashSetOf() + + var hasUnsupportedValues = false + private set + + private val builderValues: MutableMap = hashMapOf() + private var _eventManager: IEventManager? = null + val eventManager: IEventManager get() = _eventManager ?: InterfacedEventManager() + + // So we can track the initial token and intents, the constructor will be instrumented and call this method + // The user overriding the values using token/intent setters should not be an issue + @DynamicCall + fun onInit(token: String?, intents: Int) { + builderValues[ValueType.TOKEN] = token + builderValues[ValueType.INTENTS] = intents + builderValues[ValueType.CACHE_FLAGS] = enumSetOfAll() + } + + @DynamicCall + fun markUnsupportedValue(signature: String) { + if (warnedUnsupportedValues.add(signature)) + logger.warn { "Unsupported JDABuilder method '$signature', JDA will not be cached between restarts" } + hasUnsupportedValues = true + } + + @DynamicCall + fun setStatus(status: OnlineStatus) { + builderValues[ValueType.STATUS] = status + } + + @DynamicCall + fun setEventManager(eventManager: IEventManager?) { + _eventManager = eventManager + } + + @DynamicCall + fun setEventPassthrough(enable: Boolean) { + builderValues[ValueType.EVENT_PASSTHROUGH] = enable + } + + @DynamicCall + @Suppress("UNCHECKED_CAST") + fun enableCache(first: CacheFlag, vararg others: CacheFlag) { + (builderValues[ValueType.CACHE_FLAGS] as EnumSet) += enumSetOf(first, *others) + } + + @DynamicCall + @Suppress("UNCHECKED_CAST") + fun enableCache(flags: Collection) { + (builderValues[ValueType.CACHE_FLAGS] as EnumSet) += flags + } + + @DynamicCall + @Suppress("UNCHECKED_CAST") + fun disableCache(first: CacheFlag, vararg others: CacheFlag) { + (builderValues[ValueType.CACHE_FLAGS] as EnumSet) -= enumSetOf(first, *others) + } + + @DynamicCall + @Suppress("UNCHECKED_CAST") + fun disableCache(flags: Collection) { + (builderValues[ValueType.CACHE_FLAGS] as EnumSet) -= flags + } + + @DynamicCall + fun setMemberCachePolicy(memberCachePolicy: MemberCachePolicy?) { + builderValues[ValueType.MEMBER_CACHE_POLICY] = memberCachePolicy + } + + @DynamicCall + fun setChunkingFilter(filter: ChunkingFilter?) { + builderValues[ValueType.CHUNKING_FILTER] = filter + } + + @DynamicCall + fun setLargeThreshold(threshold: Int) { + builderValues[ValueType.LARGE_THRESHOLD] = threshold + } + + @DynamicCall + fun setActivity(activity: Activity?) { + builderValues[ValueType.ACTIVITY] = activity + } + + @DynamicCall + fun setEnableShutdownHook(enable: Boolean) { + builderValues[ValueType.ENABLE_SHUTDOWN_HOOK] = enable + } + + internal infix fun isSameAs(other: JDABuilderConfiguration): Boolean { + if (hasUnsupportedValues) return false + if (other.hasUnsupportedValues) return false + + return builderValues == other.builderValues + } + + private enum class ValueType { + TOKEN, + INTENTS, + STATUS, + EVENT_PASSTHROUGH, + CACHE_FLAGS, + // These two are interfaces, it's fine to compare them by equality, + // their reference will be the same as they are from the app class loader, + // so if two runs uses MemberCachePolicy#VOICE, it'll still be compatible + MEMBER_CACHE_POLICY, + CHUNKING_FILTER, + LARGE_THRESHOLD, + ACTIVITY, + ENABLE_SHUTDOWN_HOOK, + } +} \ No newline at end of file diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/JDABuilderSession.kt b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/JDABuilderSession.kt new file mode 100644 index 000000000..d4301120c --- /dev/null +++ b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/JDABuilderSession.kt @@ -0,0 +1,175 @@ +package dev.freya02.botcommands.restart.jda.cache + +import dev.freya02.botcommands.restart.jda.cache.utils.isJvmShuttingDown +import io.github.freya022.botcommands.api.core.BContext +import io.github.oshai.kotlinlogging.KotlinLogging +import net.dv8tion.jda.api.JDA +import net.dv8tion.jda.api.events.StatusChangeEvent +import net.dv8tion.jda.api.events.guild.GuildReadyEvent +import net.dv8tion.jda.api.events.session.ReadyEvent +import java.util.function.Supplier + +private val logger = KotlinLogging.logger { } + +// TODO there may be an issue with REST requests, +// as the instance will not get shut down, the requester will still run any request currently queued +// so we should find a way to cancel the tasks in the rate limiter + +// TODO a similar feature exists at https://github.com/LorittaBot/DeviousJDA/blob/master/src/examples/java/SessionCheckpointAndGatewayResumeExample.kt +// however as it is a JDA fork, users will not be able to use the latest features, +// there is also a risk that the saved data (checkpoint) could miss fields + +// TODO another way of building this feature is to have the user use an external gateway proxy, such as https://github.com/Gelbpunkt/gateway-proxy +// however such a solution introduces a lot of friction, +// requiring to set up JDA manually, though not complicated, but also docker and that container's config +// An hybrid way would require rewriting that proxy, +// so our module can hook into JDA and set the gateway URL to the proxy's +internal class JDABuilderSession private constructor( + @get:DynamicCall val key: String, +) { + + @get:DynamicCall + val configuration = JDABuilderConfiguration() + var wasBuilt: Boolean = false + private set + + private lateinit var scheduleShutdownSignal: ScheduleShutdownSignalWrapper + + // May also be shutdownNow + @DynamicCall + fun onShutdown(instance: JDA, shutdownFunction: Runnable) { + if (isJvmShuttingDown()) { + // "scheduleShutdownSignal" isn't there yet if this shutdown is triggered by JDA's shutdown hook + return shutdownFunction.run() + } + + if (::scheduleShutdownSignal.isInitialized.not()) { + logger.error { "Expected BContextImpl#scheduleShutdownSignal to be called before shutdown, doing a full shut down" } + return shutdownFunction.run() + } + + // Don't save if this configuration has unsupported values + if (configuration.hasUnsupportedValues) { + scheduleShutdownSignal.runFully() + shutdownFunction.run() + return logger.debug { "Discarding JDA instance as the configuration had unsupported values (key '$key')" } + } + + val eventManager = instance.eventManager as? BufferingEventManager + eventManager?.detach() // If the event manager isn't what we expect, it will be logged when attempting to reuse + + JDACache[key] = JDACache.Data(configuration, instance, shutdownFunction, scheduleShutdownSignal) + } + + /** + * Stores the actual code of BContextImpl#scheduleShutdownSignal and the callback it was passed + * + * [scheduleShutdownSignalFunction] will be called with [afterShutdownSignal] if JDA does get shut down, + * but if JDA is reused, only [afterShutdownSignal] is used. + */ + @DynamicCall + fun onScheduleShutdownSignal(scheduleShutdownSignalFunction: Runnable, afterShutdownSignal: () -> Unit) { + this.scheduleShutdownSignal = ScheduleShutdownSignalWrapper(scheduleShutdownSignalFunction, afterShutdownSignal) + } + + @DynamicCall + fun onBuild(buildFunction: Supplier): JDA { + val jda = buildOrReuse(buildFunction) + wasBuilt = true + return jda + } + + private fun buildOrReuse(buildFunction: Supplier): JDA { + val cachedData = JDACache.remove(key) + + fun createNewInstance(): JDA { + val jda = buildFunction.get() + cachedData?.scheduleShutdownSignal?.runFully() + cachedData?.doShutdown?.run() + return jda + } + + if (configuration.hasUnsupportedValues) { + logger.debug { "Configured JDABuilder has unsupported values, building a new JDA instance (key '$key')" } + return createNewInstance() + } + + if (cachedData == null) { + logger.debug { "Creating a new JDA instance (key '$key')" } + return createNewInstance() + } + + if (cachedData.configuration isSameAs configuration) { + logger.debug { "Reusing JDA instance with compatible configuration (key '$key')" } + val jda = cachedData.jda + val eventManager = jda.eventManager as? BufferingEventManager + ?: run { + logger.warn { "Expected a BufferingEventManager but got a ${jda.eventManager.javaClass.name}, creating a new instance" } + return createNewInstance() + } + + cachedData.scheduleShutdownSignal.runAfterShutdownSignal() + + eventManager.setDelegate(configuration.eventManager) + eventManager.handle(StatusChangeEvent(jda, JDA.Status.LOADING_SUBSYSTEMS, JDA.Status.CONNECTED)) + jda.guildCache.forEachUnordered { eventManager.handle(GuildReadyEvent(jda, -1, it)) } + eventManager.handle(ReadyEvent(jda)) + return jda + } else { + logger.debug { "Creating a new JDA instance as its configuration changed (key '$key')" } + return createNewInstance() + } + } + + internal class ScheduleShutdownSignalWrapper internal constructor( + private val scheduleShutdownSignalFunction: Runnable, + private val afterShutdownSignal: () -> Unit + ) { + + internal fun runFully(): Unit = scheduleShutdownSignalFunction.run() + + internal fun runAfterShutdownSignal(): Unit = afterShutdownSignal() + } + + companion object { + // I would store them in a Map, but JDABuilder has no idea what the key is + private val activeSession: ThreadLocal = + ThreadLocal.withInitial { error("No JDABuilderSession exists for this thread") } + + private val sessions: MutableMap = hashMapOf() + + @JvmStatic + @DynamicCall + fun currentSession(): JDABuilderSession = activeSession.get() + + @JvmStatic + @DynamicCall + fun getSession(key: String): JDABuilderSession { + return sessions[key] ?: error("No JDABuilderSession exists for key '$key'") + } + + @JvmStatic + @DynamicCall + fun getCacheKey(context: BContext): String? = context.config.restartConfig.cacheKey + + @JvmStatic + @DynamicCall + fun withBuilderSession( + key: String, + // Use Java function types to make codegen a bit more reliable + block: Runnable + ) { + val session = JDABuilderSession(key) + sessions[key] = session + activeSession.set(session) + try { + block.run() + if (!session.wasBuilt) { + logger.warn { "Could not save/restore any JDA session as none were built" } + } + } finally { + activeSession.remove() + } + } + } +} \ No newline at end of file diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/JDACache.kt b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/JDACache.kt new file mode 100644 index 000000000..7bc004aa0 --- /dev/null +++ b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/JDACache.kt @@ -0,0 +1,21 @@ +package dev.freya02.botcommands.restart.jda.cache + +import net.dv8tion.jda.api.JDA + +internal object JDACache { + + private val cache: MutableMap = hashMapOf() + + internal operator fun set(key: String, data: Data) { + cache[key] = data + } + + internal fun remove(key: String): Data? = cache.remove(key) + + internal class Data internal constructor( + val configuration: JDABuilderConfiguration, + val jda: JDA, + val doShutdown: Runnable, + val scheduleShutdownSignal: JDABuilderSession.ScheduleShutdownSignalWrapper, + ) +} \ No newline at end of file diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/AbstractClassFileTransformer.kt b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/AbstractClassFileTransformer.kt new file mode 100644 index 000000000..39aa209f6 --- /dev/null +++ b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/AbstractClassFileTransformer.kt @@ -0,0 +1,27 @@ +package dev.freya02.botcommands.restart.jda.cache.transformer + +import java.lang.instrument.ClassFileTransformer +import java.security.ProtectionDomain + +internal abstract class AbstractClassFileTransformer protected constructor( + private val target: String +) : ClassFileTransformer { + + override fun transform( + loader: ClassLoader?, + className: String, + classBeingRedefined: Class<*>?, + protectionDomain: ProtectionDomain, + classfileBuffer: ByteArray + ): ByteArray? { + if (className == target) return try { + transform(classfileBuffer) + } catch (e: Throwable) { + e.printStackTrace() + null + } + return null + } + + protected abstract fun transform(classData: ByteArray): ByteArray +} \ No newline at end of file diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/BContextImplTransformer.kt b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/BContextImplTransformer.kt new file mode 100644 index 000000000..ea3c9c8f7 --- /dev/null +++ b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/BContextImplTransformer.kt @@ -0,0 +1,98 @@ +package dev.freya02.botcommands.restart.jda.cache.transformer + +import dev.freya02.botcommands.restart.jda.cache.transformer.utils.* +import io.github.oshai.kotlinlogging.KotlinLogging +import java.lang.classfile.* +import java.lang.constant.ConstantDescs.CD_String +import java.lang.constant.ConstantDescs.CD_void +import java.lang.constant.MethodTypeDesc +import java.lang.reflect.AccessFlag + +private val logger = KotlinLogging.logger { } + +internal object BContextImplTransformer : AbstractClassFileTransformer("io/github/freya022/botcommands/internal/core/BContextImpl") { + + override fun transform(classData: ByteArray): ByteArray { + val classFile = ClassFile.of() + val classModel = classFile.parse(classData) + + return classFile.transformClass( + classModel, + DeferScheduleShutdownSignalTransform(classModel) + ) + } +} + +private class DeferScheduleShutdownSignalTransform(private val classModel: ClassModel) : ContextualClassTransform { + + context(classBuilder: ClassBuilder) + override fun atStartContextual() { + val targetMethod = classModel.findMethod(TARGET_NAME, TARGET_SIGNATURE) + logger.trace { "Moving ${targetMethod.toFullyQualifiedString()} to '$NEW_NAME'" } + targetMethod.transferCodeTo(NEW_NAME) + } + + context(classBuilder: ClassBuilder) + override fun acceptContextual(classElement: ClassElement) { + val methodModel = classElement as? MethodModel ?: return classBuilder.retain(classElement) + if (!methodModel.matches(TARGET_NAME, TARGET_SIGNATURE)) return classBuilder.retain(classElement) + + logger.trace { "Transforming BContextImpl#${TARGET_NAME}${TARGET_SIGNATURE} to defer shutdown signal scheduling" } + classBuilder.transformMethod(methodModel) { methodBuilder, methodElement -> + if (methodElement !is CodeModel) return@transformMethod methodBuilder.retain(methodElement) + + methodBuilder.withFlags(methodModel.flags().flagsMask().withVisibility(AccessFlag.PUBLIC)) + + methodBuilder.withCode { codeBuilder -> + val thisSlot = codeBuilder.receiverSlot() + + val afterShutdownSignalSlot = codeBuilder.parameterSlot(0) + val doScheduleShutdownSignalSlot = codeBuilder.allocateLocal(TypeKind.REFERENCE) + val sessionKeySlot = codeBuilder.allocateLocal(TypeKind.REFERENCE) + val builderSessionSlot = codeBuilder.allocateLocal(TypeKind.REFERENCE) + + // Runnable doScheduleShutdownSignal = () -> this.doScheduleShutdownSignal(afterShutdownSignal) + codeBuilder.aload(thisSlot) + codeBuilder.aload(afterShutdownSignalSlot) + codeBuilder.invokedynamic( + createLambda( + interfaceMethod = Runnable::run, + targetType = CD_BContextImpl, + targetMethod = NEW_NAME, + targetMethodReturnType = CD_void, + targetMethodArguments = listOf(), + capturedTypes = listOf(CD_Function0), + isStatic = false + ) + ) + codeBuilder.astore(doScheduleShutdownSignalSlot) + + // String sessionKey = JDABuilderSession.getCacheKey(this) + codeBuilder.aload(thisSlot) + codeBuilder.invokestatic(CD_JDABuilderSession, "getCacheKey", MethodTypeDesc.of(CD_String, CD_BContext)) + codeBuilder.astore(sessionKeySlot) + + // JDABuilderSession builderSession = JDABuilderSession.getSession(sessionKey) + codeBuilder.aload(sessionKeySlot) + codeBuilder.invokestatic(CD_JDABuilderSession, "getSession", MethodTypeDesc.of(CD_JDABuilderSession, CD_String)) + codeBuilder.astore(builderSessionSlot) + + // builderSession.onScheduleShutdownSignal(doScheduleShutdownSignal, afterShutdownSignal) + codeBuilder.aload(builderSessionSlot) + codeBuilder.aload(doScheduleShutdownSignalSlot) + codeBuilder.aload(afterShutdownSignalSlot) + codeBuilder.invokevirtual(CD_JDABuilderSession, "onScheduleShutdownSignal", MethodTypeDesc.of(CD_void, CD_Runnable, CD_Function0)) + + // Required + codeBuilder.return_() + } + } + } + + private companion object { + const val TARGET_NAME = "scheduleShutdownSignal" + const val TARGET_SIGNATURE = "(Lkotlin/jvm/functions/Function0;)V" + + const val NEW_NAME = "doScheduleShutdownSignal" + } +} diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/ClassDescriptors.kt b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/ClassDescriptors.kt new file mode 100644 index 000000000..4ddf7b172 --- /dev/null +++ b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/ClassDescriptors.kt @@ -0,0 +1,32 @@ +package dev.freya02.botcommands.restart.jda.cache.transformer + +import dev.freya02.botcommands.restart.jda.cache.BufferingEventManager +import dev.freya02.botcommands.restart.jda.cache.JDABuilderConfiguration +import dev.freya02.botcommands.restart.jda.cache.JDABuilderSession +import dev.freya02.botcommands.restart.jda.cache.transformer.utils.classDesc +import org.intellij.lang.annotations.Language +import java.lang.constant.ClassDesc + +internal val CD_Function0 = classDescOf("kotlin.jvm.functions.Function0") + +internal val CD_IllegalStateException = classDescOf("java.lang.IllegalStateException") +internal val CD_Runnable = classDescOf("java.lang.Runnable") +internal val CD_Supplier = classDescOf("java.util.function.Supplier") + +internal val CD_BContext = classDescOf("io.github.freya022.botcommands.api.core.BContext") +internal val CD_BContextImpl = classDescOf("io.github.freya022.botcommands.internal.core.BContextImpl") +internal val CD_JDAService = classDescOf("io.github.freya022.botcommands.api.core.JDAService") +internal val CD_BReadyEvent = classDescOf("io.github.freya022.botcommands.api.core.events.BReadyEvent") + +internal val CD_JDA = classDescOf("net.dv8tion.jda.api.JDA") +internal val CD_JDAImpl = classDescOf("net.dv8tion.jda.internal.JDAImpl") +internal val CD_JDABuilder = classDescOf("net.dv8tion.jda.api.JDABuilder") +internal val CD_IEventManager = classDescOf("net.dv8tion.jda.api.hooks.IEventManager") + +internal val CD_BufferingEventManager = classDesc() +internal val CD_JDABuilderSession = classDesc() +internal val CD_JDABuilderConfiguration = classDesc() + +private fun classDescOf(@Language("java", prefix = "import ", suffix = ";") name: String): ClassDesc { + return ClassDesc.of(name) +} \ No newline at end of file diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/ContextualClassTransform.kt b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/ContextualClassTransform.kt new file mode 100644 index 000000000..e857339ea --- /dev/null +++ b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/ContextualClassTransform.kt @@ -0,0 +1,25 @@ +package dev.freya02.botcommands.restart.jda.cache.transformer + +import java.lang.classfile.ClassBuilder +import java.lang.classfile.ClassElement +import java.lang.classfile.ClassTransform + +internal interface ContextualClassTransform : ClassTransform { + + override fun atStart(builder: ClassBuilder): Unit = context(builder) { atStartContextual() } + + context(classBuilder: ClassBuilder) + fun atStartContextual() { } + + + override fun atEnd(builder: ClassBuilder): Unit = context(builder) { atEndContextual() } + + context(classBuilder: ClassBuilder) + fun atEndContextual() { } + + + override fun accept(builder: ClassBuilder, element: ClassElement): Unit = context(builder) { acceptContextual(element) } + + context(classBuilder: ClassBuilder) + fun acceptContextual(classElement: ClassElement) { } +} \ No newline at end of file diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDABuilderTransformer.kt b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDABuilderTransformer.kt new file mode 100644 index 000000000..02322160b --- /dev/null +++ b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDABuilderTransformer.kt @@ -0,0 +1,258 @@ +package dev.freya02.botcommands.restart.jda.cache.transformer + +import dev.freya02.botcommands.restart.jda.cache.JDABuilderConfiguration +import dev.freya02.botcommands.restart.jda.cache.transformer.utils.* +import io.github.oshai.kotlinlogging.KotlinLogging +import java.lang.classfile.* +import java.lang.classfile.ClassFile.* +import java.lang.constant.ClassDesc +import java.lang.constant.ConstantDescs.* +import java.lang.constant.MethodTypeDesc +import java.lang.reflect.AccessFlag +import java.util.function.Supplier + +private val logger = KotlinLogging.logger { } + +internal object JDABuilderTransformer : AbstractClassFileTransformer("net/dv8tion/jda/api/JDABuilder") { + + override fun transform(classData: ByteArray): ByteArray { + val classFile = ClassFile.of() + val classModel = classFile.parse(classData) + + return classFile.transformClass( + classModel, + CaptureSetterParametersTransform() + .andThen(CaptureConstructorParametersTransform(classModel)) + .andThen(DeferBuildAndSetBufferingEventManagerTransform(classModel)) + ) + } +} + +private class CaptureConstructorParametersTransform(private val classModel: ClassModel) : ContextualClassTransform { + + context(classBuilder: ClassBuilder) + override fun atStartContextual() { + classModel.findMethod(TARGET_NAME, TARGET_SIGNATURE) + } + + context(classBuilder: ClassBuilder) + override fun acceptContextual(classElement: ClassElement) { + val methodModel = classElement as? MethodModel ?: return classBuilder.retain(classElement) + if (!methodModel.matches(TARGET_NAME, TARGET_SIGNATURE)) return classBuilder.retain(classElement) + + logger.trace { "Transforming ${methodModel.toFullyQualifiedString()} to capture parameters" } + classBuilder.transformMethod(methodModel) { methodBuilder, methodElement -> + val codeModel = methodElement as? CodeModel ?: return@transformMethod methodBuilder.retain(methodElement) + + methodBuilder.withCode { codeBuilder -> + val builderConfigurationSlot = codeBuilder.allocateLocal(TypeKind.REFERENCE) + val tokenSlot = codeBuilder.parameterSlot(0) + val intentsSlot = codeBuilder.parameterSlot(1) + + // JDABuilderConfiguration configuration = JDABuilderSession.currentSession().getConfiguration(); + codeBuilder.invokestatic(CD_JDABuilderSession, "currentSession", MethodTypeDesc.of(CD_JDABuilderSession)) + codeBuilder.invokevirtual(CD_JDABuilderSession, "getConfiguration", MethodTypeDesc.of(CD_JDABuilderConfiguration)) + codeBuilder.astore(builderConfigurationSlot) + + // configuration.onInit(token, intents); + codeBuilder.aload(builderConfigurationSlot) + codeBuilder.aload(tokenSlot) + codeBuilder.iload(intentsSlot) + codeBuilder.invokevirtual(CD_JDABuilderConfiguration, "onInit", MethodTypeDesc.of(CD_void, CD_String, CD_int)) + + // Add existing instructions + codeModel.forEach { codeBuilder.with(it) } + } + } + } + + private companion object { + const val TARGET_NAME = "" + const val TARGET_SIGNATURE = "(Ljava/lang/String;I)V" + } +} + +private class DeferBuildAndSetBufferingEventManagerTransform(private val classModel: ClassModel) : ContextualClassTransform { + + context(classBuilder: ClassBuilder) + override fun atStartContextual() { + val targetMethod = classModel.findMethod(TARGET_NAME, TARGET_SIGNATURE) + + logger.trace { "Adding JDABuilder#${NEW_NAME}() to set an event manager and build" } + classBuilder.withMethod( + NEW_NAME, + MethodTypeDesc.of(CD_JDA), + ACC_PRIVATE or ACC_SYNTHETIC or ACC_FINAL + ) { methodBuilder -> + val codeModel = targetMethod.code().get() + + methodBuilder.withCode { codeBuilder -> + val thisSlot = codeBuilder.receiverSlot() + + val bufferingEventManagerSlot = codeBuilder.allocateLocal(TypeKind.REFERENCE) + + // JDABuilder's eventManager is null by default, + // however, the framework mandates setting a framework-provided event manager, + // so let's just throw if it is null. + val nullEventManagerLabel = codeBuilder.newLabel() + codeBuilder.aload(thisSlot) + codeBuilder.getfield(CD_JDABuilder, "eventManager", CD_IEventManager) + codeBuilder.ifnull(nullEventManagerLabel) + + // var bufferingEventManager = new BufferingEventManager + codeBuilder.new_(CD_BufferingEventManager) + codeBuilder.astore(bufferingEventManagerSlot) + + // bufferingEventManager.(eventManager) + codeBuilder.aload(bufferingEventManagerSlot) + codeBuilder.aload(thisSlot) + codeBuilder.getfield(CD_JDABuilder, "eventManager", CD_IEventManager) + codeBuilder.invokespecial(CD_BufferingEventManager, "", MethodTypeDesc.of(CD_void, CD_IEventManager)) + + // this.setEventManager(eventManager) + codeBuilder.aload(thisSlot) + codeBuilder.aload(bufferingEventManagerSlot) + codeBuilder.invokevirtual(CD_JDABuilder, "setEventManager", MethodTypeDesc.of(CD_JDABuilder, CD_IEventManager)) + + // Move the build() code to doBuild() + codeModel.forEach { codeBuilder.with(it) } + + // Branch when "eventManager" is null + codeBuilder.labelBinding(nullEventManagerLabel) + + codeBuilder.new_(CD_IllegalStateException) + codeBuilder.dup() + codeBuilder.ldc("The event manager must be set using the one provided in JDAService#createJDA") + codeBuilder.invokespecial(CD_IllegalStateException, "", MethodTypeDesc.of(CD_void, CD_String)) + codeBuilder.athrow() + } + } + } + + context(classBuilder: ClassBuilder) + override fun acceptContextual(classElement: ClassElement) { + val methodModel = classElement as? MethodModel ?: return classBuilder.retain(classElement) + if (!methodModel.matches(TARGET_NAME, TARGET_SIGNATURE)) return classBuilder.retain(classElement) + + logger.trace { "Transforming ${methodModel.toFullyQualifiedString()} to defer calls" } + classBuilder.transformMethod(methodModel) { methodBuilder, methodElement -> + if (methodElement !is CodeModel) return@transformMethod methodBuilder.retain(methodElement) + + methodBuilder.withCode { codeBuilder -> + val thisSlot = codeBuilder.receiverSlot() + + val builderSessionSlot = codeBuilder.allocateLocal(TypeKind.REFERENCE) + val doBuildSlot = codeBuilder.allocateLocal(TypeKind.REFERENCE) + val jdaSlot = codeBuilder.allocateLocal(TypeKind.REFERENCE) + + // Supplier doBuild = this::doBuild + codeBuilder.aload(thisSlot) + + codeBuilder.invokedynamic( + createLambda( + interfaceMethod = Supplier<*>::get, + targetType = CD_JDABuilder, + targetMethod = NEW_NAME, + targetMethodReturnType = CD_JDA, + targetMethodArguments = listOf(), + capturedTypes = emptyList(), + isStatic = false + ) + ) + codeBuilder.astore(doBuildSlot) + + // JDABuilderSession session = JDABuilderSession.currentSession(); + codeBuilder.invokestatic(CD_JDABuilderSession, "currentSession", MethodTypeDesc.of(CD_JDABuilderSession)) + codeBuilder.astore(builderSessionSlot) + + // var jda = session.onBuild(this::doBuild); + codeBuilder.aload(builderSessionSlot) + codeBuilder.aload(doBuildSlot) + codeBuilder.invokevirtual(CD_JDABuilderSession, "onBuild", MethodTypeDesc.of(CD_JDA, CD_Supplier)) + // Again, prefer using a variable for clarity + codeBuilder.astore(jdaSlot) + + codeBuilder.aload(jdaSlot) + codeBuilder.areturn() + } + } + } + + private companion object { + const val TARGET_NAME = "build" + const val TARGET_SIGNATURE = "()Lnet/dv8tion/jda/api/JDA;" + + const val NEW_NAME = "doBuild" + } +} + +private class CaptureSetterParametersTransform : ContextualClassTransform { + + private val builderSessionMethods: Set = ClassFile.of() + .parse(JDABuilderConfiguration::class.java.getResourceAsStream("JDABuilderConfiguration.class")!!.readAllBytes()) + .methods() + .mapTo(hashSetOf(), ::MethodDesc) + + context(classBuilder: ClassBuilder) + override fun acceptContextual(classElement: ClassElement) { + val methodModel = classElement as? MethodModel ?: return classBuilder.retain(classElement) + if (!methodModel.flags().has(AccessFlag.PUBLIC)) return classBuilder.retain(classElement) + if (methodModel.flags().has(AccessFlag.STATIC)) return classBuilder.retain(classElement) + if (methodModel.methodName().stringValue() == "build") return classBuilder.retain(classElement) + + // Log is done later + classBuilder.transformMethod(methodModel) { methodBuilder, methodElement -> + val codeModel = methodElement as? CodeModel ?: return@transformMethod methodBuilder.retain(methodElement) + + val hasBuilderSessionMethod = methodModel.let(::MethodDesc) in builderSessionMethods + methodBuilder.withCode { codeBuilder -> + val builderConfigurationSlot = codeBuilder.allocateLocal(TypeKind.REFERENCE) + + // JDABuilderConfiguration configuration = JDABuilderSession.currentSession().getConfiguration(); + codeBuilder.invokestatic(CD_JDABuilderSession, "currentSession", MethodTypeDesc.of(CD_JDABuilderSession)) + codeBuilder.invokevirtual(CD_JDABuilderSession, "getConfiguration", MethodTypeDesc.of(CD_JDABuilderConfiguration)) + codeBuilder.astore(builderConfigurationSlot) + + val methodName = methodModel.methodName().stringValue() + if (hasBuilderSessionMethod) { + logger.trace { "Registering ${methodModel.toFullyQualifiedString()} as a cache-compatible method" } + + // Set return type to "void" because our method won't return JDABuilder, and it doesn't matter anyway + val methodType = methodModel.methodTypeSymbol().changeReturnType(CD_void) + + // configuration.theMethod(parameters); + codeBuilder.aload(builderConfigurationSlot) + methodType.parameterList().forEachIndexed { index, parameter -> + val typeKind = TypeKind.fromDescriptor(parameter.descriptorString()) + val slot = codeBuilder.parameterSlot(index) + codeBuilder.loadLocal(typeKind, slot) + } + codeBuilder.invokevirtual(CD_JDABuilderConfiguration, methodName, methodType) + } else { + logger.trace { "Skipping ${methodModel.toFullyQualifiedString()} as it does not have an equivalent method handler" } + + val signature = methodName + "(${methodModel.methodTypeSymbol().parameterList().joinToString { it.displayName() }})" + + // configuration.markUnsupportedValue() + codeBuilder.aload(builderConfigurationSlot) + codeBuilder.ldc(signature) + codeBuilder.invokevirtual(CD_JDABuilderConfiguration, "markUnsupportedValue", MethodTypeDesc.of(CD_void, CD_String)) + } + + // Add existing instructions + codeModel.forEach { codeBuilder.with(it) } + } + } + } + + // Utility to match methods using their name and parameters, but not return type + private data class MethodDesc( + val name: String, + val paramTypes: List + ) { + constructor(methodModel: MethodModel) : this( + methodModel.methodName().stringValue(), + methodModel.methodTypeSymbol().parameterList(), + ) + } +} \ No newline at end of file diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDAImplTransformer.kt b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDAImplTransformer.kt new file mode 100644 index 000000000..b14423534 --- /dev/null +++ b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDAImplTransformer.kt @@ -0,0 +1,242 @@ +package dev.freya02.botcommands.restart.jda.cache.transformer + +import dev.freya02.botcommands.restart.jda.cache.transformer.utils.* +import io.github.oshai.kotlinlogging.KotlinLogging +import java.lang.classfile.* +import java.lang.classfile.ClassFile.* +import java.lang.classfile.instruction.InvokeInstruction +import java.lang.constant.ConstantDescs.CD_String +import java.lang.constant.ConstantDescs.CD_void +import java.lang.constant.MethodTypeDesc + +private val logger = KotlinLogging.logger { } + +internal object JDAImplTransformer : AbstractClassFileTransformer("net/dv8tion/jda/internal/JDAImpl") { + + override fun transform(classData: ByteArray): ByteArray { + val classFile = ClassFile.of() + val classModel = classFile.parse(classData) + return classFile.transformClass( + classModel, + CaptureSessionKeyTransform() + .andThen(DeferShutdownTransform(classModel)) + .andThen(DeferShutdownNowTransform(classModel)) + .andThen(AwaitShutdownTransform()) + ) + } +} + +private class CaptureSessionKeyTransform : ContextualClassTransform { + + context(classBuilder: ClassBuilder) + override fun atStartContextual() { + logger.trace { "Adding JDAImpl#${CACHE_KEY_NAME}" } + classBuilder.withField(CACHE_KEY_NAME, CD_String, ACC_PRIVATE or ACC_FINAL) + + logger.trace { "Adding JDAImpl#getBuilderSession()" } + classBuilder.withMethod("getBuilderSession", MethodTypeDesc.of(CD_JDABuilderSession), ACC_PUBLIC) { methodBuilder -> + methodBuilder.withCode { codeBuilder -> + codeBuilder.aload(codeBuilder.receiverSlot()) + codeBuilder.getfield(CD_JDAImpl, CACHE_KEY_NAME, CD_String) + codeBuilder.invokestatic(CD_JDABuilderSession, "getSession", MethodTypeDesc.of(CD_JDABuilderSession, CD_String)) + codeBuilder.areturn() + } + } + } + + context(classBuilder: ClassBuilder) + override fun acceptContextual(classElement: ClassElement) { + val methodModel = classElement as? MethodModel ?: return classBuilder.retain(classElement) + // No need to check the signature, we can assign the field in all constructors + if (!methodModel.methodName().equalsString("")) return classBuilder.retain(classElement) + + logger.trace { "Transforming ${methodModel.toFullyQualifiedString()} to store the session key" } + classBuilder.transformMethod(methodModel) { methodBuilder, methodElement -> + val codeModel = methodElement as? CodeModel ?: return@transformMethod methodBuilder.retain(methodElement) + + methodBuilder.withCode { codeBuilder -> + val thisSlot = codeBuilder.receiverSlot() + + // this.cacheKey = JDABuilderSession.currentSession().getKey() + codeBuilder.aload(thisSlot) + codeBuilder.invokestatic(CD_JDABuilderSession, "currentSession", MethodTypeDesc.of(CD_JDABuilderSession)) + codeBuilder.invokevirtual(CD_JDABuilderSession, "getKey", MethodTypeDesc.of(CD_String)) + codeBuilder.putfield(CD_JDAImpl, CACHE_KEY_NAME, CD_String) + + // Add existing instructions + codeModel.forEach { codeBuilder.with(it) } + } + } + } + + private companion object { + const val CACHE_KEY_NAME = "cacheKey" + } +} + +private class DeferShutdownTransform(private val classModel: ClassModel) : ContextualClassTransform { + + context(classBuilder: ClassBuilder) + override fun atStartContextual() { + val targetMethod = classModel.findMethod(TARGET_NAME, TARGET_SIGNATURE) + + logger.trace { "Moving ${targetMethod.toFullyQualifiedString()} to '$NEW_NAME'" } + targetMethod.transferCodeTo(NEW_NAME) + } + + context(classBuilder: ClassBuilder) + override fun acceptContextual(classElement: ClassElement) { + val methodModel = classElement as? MethodModel ?: return classBuilder.retain(classElement) + if (!methodModel.matches(TARGET_NAME, TARGET_SIGNATURE)) return classBuilder.retain(classElement) + + logger.trace { "Transforming ${methodModel.toFullyQualifiedString()} to defer execution" } + classBuilder.transformMethod(methodModel) { methodBuilder, methodElement -> + if (methodElement !is CodeModel) return@transformMethod methodBuilder.retain(methodElement) + + methodBuilder.withCode { codeBuilder -> + val thisSlot = codeBuilder.receiverSlot() + + val doShutdownSlot = codeBuilder.allocateLocal(TypeKind.REFERENCE) + val builderSessionSlot = codeBuilder.allocateLocal(TypeKind.REFERENCE) + + // Runnable doShutdown = this::doShutdown + codeBuilder.aload(thisSlot) + codeBuilder.invokedynamic( + createLambda( + interfaceMethod = Runnable::run, + targetType = CD_JDAImpl, + targetMethod = NEW_NAME, + targetMethodReturnType = CD_void, + targetMethodArguments = listOf(), + capturedTypes = listOf(), + isStatic = false + ) + ) + codeBuilder.astore(doShutdownSlot) + + // var builderSession = getBuilderSession() + codeBuilder.aload(thisSlot) + codeBuilder.invokevirtual(CD_JDAImpl, "getBuilderSession", MethodTypeDesc.of(CD_JDABuilderSession)) + codeBuilder.astore(builderSessionSlot) + + // builderSession.onShutdown(this, this::doShutdown); + codeBuilder.aload(builderSessionSlot) + codeBuilder.aload(thisSlot) + codeBuilder.aload(doShutdownSlot) + codeBuilder.invokevirtual(CD_JDABuilderSession, "onShutdown", MethodTypeDesc.of(CD_void, CD_JDA, CD_Runnable)) + + codeBuilder.return_() + } + } + } + + private companion object { + const val TARGET_NAME = "shutdown" + const val TARGET_SIGNATURE = "()V" + + const val NEW_NAME = "doShutdown" + } +} + +private class DeferShutdownNowTransform(private val classModel: ClassModel) : ContextualClassTransform { + + context(classBuilder: ClassBuilder) + override fun atStartContextual() { + val targetMethod = classModel.findMethod(TARGET_NAME, TARGET_SIGNATURE) + + logger.trace { "Moving ${targetMethod.toFullyQualifiedString()} to $NEW_NAME, replacing shutdown() with doShutdown()" } + classBuilder.withMethod( + NEW_NAME, + MethodTypeDesc.of(CD_void), + ACC_PRIVATE or ACC_SYNTHETIC or ACC_FINAL + ) { methodBuilder -> + val codeModel = targetMethod.code().get() + + methodBuilder.withCode { codeBuilder -> + // Move the shutdownNow() code to doShutdownNow() + codeModel.forEach { codeElement -> + // Replace shutdown() with doShutdown() so we don't call [[JDABuilderSession#onShutdown]] more than once + if (codeElement is InvokeInstruction && codeElement.name().equalsString("shutdown")) { + require(codeElement.type().equalsString("()V")) + codeBuilder.invokevirtual(codeElement.owner().asSymbol(), "doShutdown", MethodTypeDesc.of(CD_void)) + return@forEach + } + + codeBuilder.with(codeElement) + } + } + } + } + + context(classBuilder: ClassBuilder) + override fun acceptContextual(classElement: ClassElement) { + val methodModel = classElement as? MethodModel ?: return classBuilder.retain(classElement) + if (!methodModel.matches(TARGET_NAME, TARGET_SIGNATURE)) return classBuilder.retain(classElement) + + logger.trace { "Transforming ${methodModel.toFullyQualifiedString()} to defer execution" } + classBuilder.transformMethod(methodModel) { methodBuilder, methodElement -> + if (methodElement !is CodeModel) return@transformMethod methodBuilder.retain(methodElement) + + methodBuilder.withCode { codeBuilder -> + val thisSlot = codeBuilder.receiverSlot() + + val doShutdownNowSlot = codeBuilder.allocateLocal(TypeKind.REFERENCE) + val builderSessionSlot = codeBuilder.allocateLocal(TypeKind.REFERENCE) + + // Runnable doShutdownNow = this::doShutdownNow + codeBuilder.aload(thisSlot) + codeBuilder.invokedynamic( + createLambda( + interfaceMethod = Runnable::run, + targetType = CD_JDAImpl, + targetMethod = NEW_NAME, + targetMethodReturnType = CD_void, + targetMethodArguments = listOf(), + capturedTypes = listOf(), + isStatic = false + ) + ) + codeBuilder.astore(doShutdownNowSlot) + + // var builderSession = getBuilderSession() + codeBuilder.aload(thisSlot) + codeBuilder.invokevirtual(CD_JDAImpl, "getBuilderSession", MethodTypeDesc.of(CD_JDABuilderSession)) + codeBuilder.astore(builderSessionSlot) + + // builderSession.onShutdown(this, this::doShutdownNow); + codeBuilder.aload(builderSessionSlot) + codeBuilder.aload(thisSlot) + codeBuilder.aload(doShutdownNowSlot) + codeBuilder.invokevirtual(CD_JDABuilderSession, "onShutdown", MethodTypeDesc.of(CD_void, CD_JDA, CD_Runnable)) + + codeBuilder.return_() + } + } + } + + private companion object { + const val TARGET_NAME = "shutdownNow" + const val TARGET_SIGNATURE = "()V" + + const val NEW_NAME = "doShutdownNow" + } +} + +private class AwaitShutdownTransform : ContextualClassTransform { + + context(classBuilder: ClassBuilder) + override fun acceptContextual(classElement: ClassElement) { + val methodModel = classElement as? MethodModel ?: return classBuilder.retain(classElement) + if (!methodModel.methodName().equalsString("awaitShutdown")) return classBuilder.retain(classElement) + + logger.trace { "Transforming ${methodModel.toFullyQualifiedString()} to immediately return" } + classBuilder.transformMethod(methodModel) { methodBuilder, methodElement -> + if (methodElement !is CodeModel) return@transformMethod methodBuilder.retain(methodElement) + + methodBuilder.withCode { codeBuilder -> + codeBuilder.iconst_0() + codeBuilder.ireturn() + } + } + } +} \ No newline at end of file diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDAServiceTransformer.kt b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDAServiceTransformer.kt new file mode 100644 index 000000000..04e032bab --- /dev/null +++ b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDAServiceTransformer.kt @@ -0,0 +1,114 @@ +package dev.freya02.botcommands.restart.jda.cache.transformer + +import dev.freya02.botcommands.restart.jda.cache.transformer.utils.* +import io.github.oshai.kotlinlogging.KotlinLogging +import java.lang.classfile.* +import java.lang.constant.ConstantDescs.CD_String +import java.lang.constant.ConstantDescs.CD_void +import java.lang.constant.MethodTypeDesc + +private val logger = KotlinLogging.logger { } + +internal object JDAServiceTransformer : AbstractClassFileTransformer("io/github/freya022/botcommands/api/core/JDAService") { + + override fun transform(classData: ByteArray): ByteArray { + val classFile = ClassFile.of() + val classModel = classFile.parse(classData) + + return classFile.transformClass( + classModel, + WrapOnReadyEventWithJDABuilderSessionTransform(classModel) + ) + } +} + +private class WrapOnReadyEventWithJDABuilderSessionTransform(private val classModel: ClassModel) : ContextualClassTransform { + + context(classBuilder: ClassBuilder) + override fun atStartContextual() { + // Put the original code of onReadyEvent in the lambda, + // it will be fired by JDABuilderSession.withBuilderSession in onReadyEvent + val targetMethod = classModel.findMethod(TARGET_NAME, TARGET_SIGNATURE) + + logger.trace { "Moving ${targetMethod.toFullyQualifiedString()} to '$NEW_NAME'" } + targetMethod.transferCodeTo(NEW_NAME) + } + + context(classBuilder: ClassBuilder) + override fun acceptContextual(classElement: ClassElement) { + val methodModel = classElement as? MethodModel ?: return classBuilder.retain(classElement) + if (!methodModel.matches(TARGET_NAME, TARGET_SIGNATURE)) return classBuilder.retain(classElement) + + logger.trace { "Transforming ${methodModel.toFullyQualifiedString()} to wrap the code in a build session" } + classBuilder.transformMethod(methodModel) { methodBuilder, methodElement -> + if (methodElement !is CodeModel) return@transformMethod methodBuilder.retain(methodElement) + + methodBuilder.withCode { codeBuilder -> + val thisSlot = codeBuilder.receiverSlot() + + val readyEventSlot = codeBuilder.parameterSlot(0) + val eventManagerSlot = codeBuilder.parameterSlot(1) + + val contextSlot = codeBuilder.allocateLocal(TypeKind.REFERENCE) + val sessionKeySlot = codeBuilder.allocateLocal(TypeKind.REFERENCE) + val sessionRunnableSlot = codeBuilder.allocateLocal(TypeKind.REFERENCE) + + // var context = event.getContext() + // We could inline this to avoid a successive store/load, + // but I think using variables is probably a better practice, let's leave the optimization to the VM + codeBuilder.aload(readyEventSlot) + codeBuilder.invokevirtual(CD_BReadyEvent, "getContext", MethodTypeDesc.of(CD_BContext)) + codeBuilder.astore(contextSlot) + + // var key = JDABuilderSession.getCacheKey(context) + codeBuilder.aload(contextSlot) + codeBuilder.invokestatic(CD_JDABuilderSession, "getCacheKey", MethodTypeDesc.of(CD_String, CD_BContext)) + codeBuilder.astore(sessionKeySlot) + + // THE KEY IS NULLABLE + // If it is, then don't make a session + val nullKeyLabel = codeBuilder.newLabel() + + // if (key == null) -> nullKeyLabel + codeBuilder.aload(sessionKeySlot) + codeBuilder.ifnull(nullKeyLabel) + + // Runnable sessionRunnable = () -> [lambdaName](event, eventManager) + codeBuilder.aload(thisSlot) + codeBuilder.aload(readyEventSlot) + codeBuilder.aload(eventManagerSlot) + codeBuilder.invokedynamic( + createLambda( + interfaceMethod = Runnable::run, + targetType = CD_JDAService, + targetMethod = NEW_NAME, + targetMethodReturnType = CD_void, + targetMethodArguments = listOf(), + capturedTypes = listOf(CD_BReadyEvent, CD_IEventManager), + isStatic = false + ) + ) + codeBuilder.astore(sessionRunnableSlot) + + // JDABuilderSession.withBuilderSession(key, sessionRunnable) + codeBuilder.aload(sessionKeySlot) + codeBuilder.aload(sessionRunnableSlot) + codeBuilder.invokestatic(CD_JDABuilderSession, "withBuilderSession", MethodTypeDesc.of(CD_void, CD_String, CD_Runnable)) + + // Required + codeBuilder.return_() + + // nullKeyLabel code + codeBuilder.labelBinding(nullKeyLabel) + codeBuilder.return_() + } + } + } + + private companion object { + const val TARGET_NAME = $$"onReadyEvent$BotCommands" + const val TARGET_SIGNATURE = "(Lio/github/freya022/botcommands/api/core/events/BReadyEvent;Lnet/dv8tion/jda/api/hooks/IEventManager;)V" + + const val NEW_NAME = $$"lambda$onReadyEvent$BotCommands$withBuilderSession" + } +} \ No newline at end of file diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/utils/CodeBuilderUtils.kt b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/utils/CodeBuilderUtils.kt new file mode 100644 index 000000000..04e4f22f9 --- /dev/null +++ b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/utils/CodeBuilderUtils.kt @@ -0,0 +1,87 @@ +package dev.freya02.botcommands.restart.jda.cache.transformer.utils + +import java.lang.classfile.ClassFileBuilder +import java.lang.classfile.ClassFileElement +import java.lang.classfile.CodeBuilder +import java.lang.constant.* +import java.lang.constant.ConstantDescs.CD_String +import java.lang.invoke.* +import kotlin.reflect.KFunction +import kotlin.reflect.jvm.javaMethod + +internal inline fun classDesc(): ClassDesc = ClassDesc.of(T::class.java.name) + +internal fun ClassFileBuilder.retain(element: E) { + with(element) +} + +internal fun CodeBuilder.ldc(string: String) { + ldc(string as java.lang.String) +} + +internal val lambdaMetafactoryDesc = MethodHandleDesc.ofMethod( + DirectMethodHandleDesc.Kind.STATIC, + classDesc(), + "metafactory", + MethodTypeDesc.of( + classDesc(), + classDesc(), + CD_String, + classDesc(), + classDesc(), + classDesc(), + classDesc() + ) +) + +internal fun createLambda( + interfaceMethod: KFunction<*>, + targetType: ClassDesc, + targetMethod: String, + targetMethodReturnType: ClassDesc, + targetMethodArguments: List, + capturedTypes: List, + isStatic: Boolean, +): DynamicCallSiteDesc { + val effectiveCapturedTypes = when { + isStatic -> capturedTypes + else -> listOf(targetType) + capturedTypes + } + + fun Class<*>.toClassDesc(): ClassDesc = + describeConstable().orElseThrow { IllegalArgumentException("$name cannot be transformed to a ClassDesc") } + + val interfaceJavaMethod = interfaceMethod.javaMethod!! + val targetInterface = interfaceJavaMethod.declaringClass.toClassDesc() + val methodReturnType = interfaceJavaMethod.returnType.toClassDesc() + val methodArguments = interfaceJavaMethod.parameterTypes.map { it.toClassDesc() } + + return DynamicCallSiteDesc.of( + lambdaMetafactoryDesc, + // The following parameters are from [[LambdaMetafactory#metafactory]] + // This is the 2nd argument of LambdaMetafactory#metafactory, "interfaceMethodName", + // the method name in Runnable is "run" + interfaceMethod.name, + // This is the 3rd argument of LambdaMetafactory#metafactory, "factoryType", + // the return type is the implemented interface, + // while the parameters are the captured variables + MethodTypeDesc.of(targetInterface, effectiveCapturedTypes), + // Bootstrap arguments (see `javap -c -v ` from a working .java sample) + // This is the 4th argument of LambdaMetafactory#metafactory, "interfaceMethodType", + // which is the signature of the implemented method, in this case, void Runnable.run() + MethodTypeDesc.of(methodReturnType, methodArguments), + // This is the 5th argument of LambdaMetafactory#metafactory, "implementation", + // this is the method to be called when invoking the lambda, + // with the captured variables and parameters + MethodHandleDesc.ofMethod( + if (isStatic) DirectMethodHandleDesc.Kind.STATIC else DirectMethodHandleDesc.Kind.VIRTUAL, + targetType, + targetMethod, + MethodTypeDesc.of(targetMethodReturnType, capturedTypes + targetMethodArguments) + ), + // This is the 6th argument of LambdaMetafactory#metafactory, "dynamicMethodType", + // this is "the signature and return type to be enforced dynamically at invocation type" + // This is usually the same as "interfaceMethodType" + MethodTypeDesc.of(methodReturnType, methodArguments), + ) +} \ No newline at end of file diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/utils/TransformUtils.kt b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/utils/TransformUtils.kt new file mode 100644 index 000000000..4f59e6f47 --- /dev/null +++ b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/utils/TransformUtils.kt @@ -0,0 +1,42 @@ +package dev.freya02.botcommands.restart.jda.cache.transformer.utils + +import java.lang.classfile.ClassBuilder +import java.lang.classfile.ClassFile.ACC_SYNTHETIC +import java.lang.classfile.ClassModel +import java.lang.classfile.MethodModel +import java.lang.reflect.AccessFlag +import kotlin.jvm.optionals.getOrNull + +internal fun Int.withVisibility(visibility: AccessFlag?): Int { + var flags = this + flags = flags and (AccessFlag.PUBLIC.mask() or AccessFlag.PROTECTED.mask() or AccessFlag.PRIVATE.mask()).inv() + if (visibility != null) // null = package-private + flags = flags or visibility.mask() + return flags +} + +internal fun MethodModel.matches(name: String, signature: String): Boolean { + return methodName().equalsString(name) && methodType().equalsString(signature) +} + +internal fun ClassModel.findMethod(name: String, signature: String): MethodModel { + return this.methods().firstOrNull { it.matches(name, signature) } + ?: error("Could not find ${this.thisClass().name().stringValue()}#$name$signature") +} + +context(classBuilder: ClassBuilder) +internal fun MethodModel.transferCodeTo(targetMethodName: String, visibility: AccessFlag = AccessFlag.PRIVATE) { + classBuilder.withMethodBody( + classBuilder.constantPool().utf8Entry(targetMethodName), + methodType(), + visibility.mask() or ACC_SYNTHETIC // Synthetic so this doesn't require a mock + ) { codeBuilder -> + val codeModel = code().orElseThrow { IllegalArgumentException("Method ${this.toFullyQualifiedString()} does not have code") } + codeModel.forEach { codeBuilder.with(it) } + } +} + +internal fun MethodModel.toFullyQualifiedString(): String { + val className = parent().getOrNull()?.thisClass()?.asSymbol()?.displayName() ?: "" + return "$className#${methodName().stringValue()}${methodTypeSymbol().displayDescriptor()}" +} \ No newline at end of file diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/utils/JvmUtils.kt b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/utils/JvmUtils.kt new file mode 100644 index 000000000..bb941886e --- /dev/null +++ b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/utils/JvmUtils.kt @@ -0,0 +1,10 @@ +package dev.freya02.botcommands.restart.jda.cache.utils + +internal fun isJvmShuttingDown() = try { + Runtime.getRuntime().removeShutdownHook(NullShutdownHook) + false +} catch (_: IllegalStateException) { + true +} + +private object NullShutdownHook : Thread() \ No newline at end of file diff --git a/BotCommands-jda-cache/src/test/java/dev/freya02/botcommands/restart/jda/cache/Test.java b/BotCommands-jda-cache/src/test/java/dev/freya02/botcommands/restart/jda/cache/Test.java new file mode 100644 index 000000000..ccef89d6c --- /dev/null +++ b/BotCommands-jda-cache/src/test/java/dev/freya02/botcommands/restart/jda/cache/Test.java @@ -0,0 +1,34 @@ +package dev.freya02.botcommands.restart.jda.cache; + +import io.github.freya022.botcommands.api.core.JDAService; +import io.github.freya022.botcommands.api.core.events.BReadyEvent; +import net.dv8tion.jda.api.hooks.IEventManager; +import net.dv8tion.jda.api.requests.GatewayIntent; +import net.dv8tion.jda.api.utils.cache.CacheFlag; +import org.jetbrains.annotations.NotNull; + +import java.util.Set; + +public class Test extends JDAService { + + @NotNull + @Override + public Set getIntents() { + return Set.of(); + } + + @NotNull + @Override + public Set getCacheFlags() { + return Set.of(); + } + + @Override + protected void createJDA(@NotNull BReadyEvent bReadyEvent, @NotNull IEventManager iEventManager) { + System.out.println("Test"); + } + + void something(BReadyEvent bReadyEvent, IEventManager iEventManager) { + throw new IllegalStateException("test"); + } +} diff --git a/BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/BContextImplTransformerTest.kt b/BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/BContextImplTransformerTest.kt new file mode 100644 index 000000000..a03ae2da7 --- /dev/null +++ b/BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/BContextImplTransformerTest.kt @@ -0,0 +1,15 @@ +package dev.freya02.botcommands.restart.jda.cache.transformer + +import org.junit.jupiter.api.assertDoesNotThrow +import kotlin.test.Test + +class BContextImplTransformerTest { + + @Test + fun `BContextImpl is instrumented`() { + assertDoesNotThrow { + Class.forName("io.github.freya022.botcommands.internal.core.BContextImpl") + .getDeclaredMethod("doScheduleShutdownSignal", Function0::class.java) + } + } +} \ No newline at end of file diff --git a/BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDABuilderTransformerTest.kt b/BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDABuilderTransformerTest.kt new file mode 100644 index 000000000..f7989ff85 --- /dev/null +++ b/BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDABuilderTransformerTest.kt @@ -0,0 +1,135 @@ +package dev.freya02.botcommands.restart.jda.cache.transformer + +import dev.freya02.botcommands.restart.jda.cache.BufferingEventManager +import dev.freya02.botcommands.restart.jda.cache.JDABuilderConfiguration +import dev.freya02.botcommands.restart.jda.cache.JDABuilderSession +import io.mockk.* +import net.dv8tion.jda.api.JDA +import net.dv8tion.jda.api.JDABuilder +import net.dv8tion.jda.api.OnlineStatus +import net.dv8tion.jda.api.events.GenericEvent +import net.dv8tion.jda.api.hooks.IEventManager +import net.dv8tion.jda.api.requests.GatewayIntent +import okhttp3.OkHttpClient +import org.junit.jupiter.api.assertThrows +import java.util.function.Supplier +import kotlin.test.Test + +class JDABuilderTransformerTest { + + @Test + fun `Constructor is instrumented`() { + val builderConfiguration = mockk(relaxUnitFun = true) + + mockkObject(JDABuilderSession) + every { JDABuilderSession.currentSession().configuration } answers { builderConfiguration } + + JDABuilder.create("MY_TOKEN", setOf(GatewayIntent.GUILD_MEMBERS)) + + verify(exactly = 1) { builderConfiguration.onInit("MY_TOKEN", GatewayIntent.getRaw(GatewayIntent.GUILD_MEMBERS)) } + } + + @Test + fun `Unsupported instance method invalidates cache`() { + // Initial set up, this *may* call "markIncompatible" so we need to do it before really mocking + val builder = createJDABuilder() + + // Actual test + val builderConfiguration = mockk { + every { onInit(any(), any()) } just runs + every { markUnsupportedValue(any()) } just runs + } + + mockkObject(JDABuilderSession) + every { JDABuilderSession.currentSession().configuration } returns builderConfiguration + + builder.setHttpClientBuilder(OkHttpClient.Builder()) + + verify(exactly = 1) { builderConfiguration.markUnsupportedValue(any()) } + } + + @Test + fun `Instance method is instrumented`() { + // Initial set up, this *may* call "markIncompatible" so we need to do it before really mocking + val builder = createJDABuilder() + + // Actual test + val builderConfiguration = mockk { + every { onInit(any(), any()) } just runs + every { setStatus(any()) } just runs + } + + mockkObject(JDABuilderSession) + every { JDABuilderSession.currentSession().configuration } returns builderConfiguration + + builder.setStatus(OnlineStatus.DO_NOT_DISTURB) + + verify(exactly = 1) { builderConfiguration.setStatus(OnlineStatus.DO_NOT_DISTURB) } + } + + @Test + fun `Build method is instrumented`() { + val builderSession = mockk { + every { onBuild(any()) } returns mockk() + every { configuration } returns mockk(relaxUnitFun = true) + } + + mockkObject(JDABuilderSession) + every { JDABuilderSession.currentSession() } returns builderSession + + JDABuilder.createDefault("MY_TOKEN").build() + + verify(exactly = 1) { builderSession.onBuild(any()) } + } + + @Test + fun `Build sets our event manager`() { + val builderConfiguration = mockk(relaxUnitFun = true) + + val builderSession = mockk { + every { onBuild(any()) } answers { arg>(0).get() } + every { configuration } returns builderConfiguration + } + + mockkObject(JDABuilderSession) + every { JDABuilderSession.currentSession() } returns builderSession + + val builder = spyk(JDABuilder.createDefault("MY_TOKEN").setEventManager(DummyEventManager)) + + // The special event manager is set on JDABuilder#build() before any original code is run + // so we'll throw an exception on the first method call of the original code, + // which is checkIntents() + every { builder["checkIntents"]() } throws ExpectedException() + assertThrows { builder.build() } + + verify(exactly = 1) { builder.setEventManager(ofType()) } + } + + /** + * Creates a basic JDABuilder, + * call this on the first line to not record any mocking data before doing the actual test. + */ + private fun createJDABuilder(): JDABuilder { + lateinit var builder: JDABuilder + mockkObject(JDABuilderSession) { + every { JDABuilderSession.currentSession().configuration } returns mockk(relaxUnitFun = true) + + builder = JDABuilder.create("MY_TOKEN", emptySet()) + } + + return builder + } + + private object DummyEventManager : IEventManager { + + override fun register(listener: Any) {} + + override fun unregister(listener: Any) {} + + override fun handle(event: GenericEvent) {} + + override fun getRegisteredListeners(): List = emptyList() + } + + private class ExpectedException : RuntimeException() +} \ No newline at end of file diff --git a/BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDAImplTransformerTest.kt b/BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDAImplTransformerTest.kt new file mode 100644 index 000000000..da37b3f9f --- /dev/null +++ b/BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDAImplTransformerTest.kt @@ -0,0 +1,26 @@ +package dev.freya02.botcommands.restart.jda.cache.transformer + +import dev.freya02.botcommands.restart.jda.cache.JDABuilderSession +import io.mockk.* +import net.dv8tion.jda.internal.JDAImpl +import kotlin.test.Test + +class JDAImplTransformerTest { + + @Test + fun `Shutdown method is instrumented`() { + val builderSession = mockk { + every { onShutdown(any(), any()) } just runs + } + + val jda = mockk { + // If this getter is missing, then the codegen changed + every { this@mockk["getBuilderSession"]() } returns builderSession + every { shutdown() } answers { callOriginal() } + } + + jda.shutdown() + + verify(exactly = 1) { builderSession.onShutdown(jda, any()) } + } +} \ No newline at end of file diff --git a/BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDAServiceTransformerTest.kt b/BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDAServiceTransformerTest.kt new file mode 100644 index 000000000..9c7badcc5 --- /dev/null +++ b/BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDAServiceTransformerTest.kt @@ -0,0 +1,87 @@ +package dev.freya02.botcommands.restart.jda.cache.transformer + +import dev.freya02.botcommands.restart.jda.cache.JDABuilderSession +import io.github.freya022.botcommands.api.core.JDAService +import io.github.freya022.botcommands.api.core.events.BReadyEvent +import io.mockk.* +import net.dv8tion.jda.api.hooks.IEventManager +import net.dv8tion.jda.api.requests.GatewayIntent +import net.dv8tion.jda.api.utils.cache.CacheFlag +import kotlin.test.Test + +class JDAServiceTransformerTest { + + class Bot : JDAService() { + + override val intents: Set = emptySet() + override val cacheFlags: Set = emptySet() + + public override fun createJDA(event: BReadyEvent, eventManager: IEventManager) { + println("createJDA") + } + } + + @Test + fun `Event listener is instrumented`() { + mockkObject(JDABuilderSession) + every { JDABuilderSession.withBuilderSession(any(), any()) } answers { callOriginal() } // Will call createJDA + + val onReadyEvent = JDAService::class.java.getDeclaredMethod($$"onReadyEvent$BotCommands", BReadyEvent::class.java, IEventManager::class.java) + val bot = mockk { + every { createJDA(any(), any()) } just runs + every { onReadyEvent.invoke(this@mockk, any(), any()) } answers { callOriginal() } // Will call withBuilderSession + } + + val readyEvent = mockk { + every { context.config.restartConfig.cacheKey } returns "Test" + } + val eventManager = mockk() + + onReadyEvent.invoke(bot, readyEvent, eventManager) + + verify(exactly = 1) { bot.createJDA(readyEvent, eventManager) } + } + + @Test + fun `Cache key enables builder sessions`() { + mockkObject(JDABuilderSession) + every { JDABuilderSession.withBuilderSession(any(), any()) } answers { callOriginal() } + + val onReadyEvent = JDAService::class.java.getDeclaredMethod($$"onReadyEvent$BotCommands", BReadyEvent::class.java, IEventManager::class.java) + val bot = mockk { + every { createJDA(any(), any()) } just runs + every { onReadyEvent.invoke(this@mockk, any(), any()) } answers { callOriginal() } // Will call withBuilderSession + } + + val readyEvent = mockk { + every { context.config.restartConfig.cacheKey } returns "Test" + } + val eventManager = mockk() + + onReadyEvent.invoke(bot, readyEvent, eventManager) + + verify(exactly = 1) { JDABuilderSession.withBuilderSession(any(), any()) } + } + + @Test + fun `Null cache key disables builder sessions`() { + mockkObject(JDABuilderSession) + every { JDABuilderSession.withBuilderSession(any(), any()) } answers { callOriginal() } + every { JDABuilderSession.getCacheKey(any()) } answers { callOriginal() } + + val onReadyEvent = JDAService::class.java.getDeclaredMethod($$"onReadyEvent$BotCommands", BReadyEvent::class.java, IEventManager::class.java) + val bot = mockk { + every { createJDA(any(), any()) } just runs + every { onReadyEvent.invoke(this@mockk, any(), any()) } answers { callOriginal() } // Will call withBuilderSession + } + + val readyEvent = mockk { + every { context.config.restartConfig.cacheKey } returns null + } + val eventManager = mockk() + + onReadyEvent.invoke(bot, readyEvent, eventManager) + + verify(exactly = 0) { JDABuilderSession.withBuilderSession(any(), any()) } + } +} \ No newline at end of file diff --git a/BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/utils/CodeBuilderUtilsTest.kt b/BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/utils/CodeBuilderUtilsTest.kt new file mode 100644 index 000000000..5931e19ef --- /dev/null +++ b/BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/utils/CodeBuilderUtilsTest.kt @@ -0,0 +1,121 @@ +package dev.freya02.botcommands.restart.jda.cache.transformer.utils + +import dev.freya02.botcommands.restart.jda.cache.transformer.* +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import java.lang.constant.* +import java.util.function.Supplier +import kotlin.test.assertEquals + +object CodeBuilderUtilsTest { + + @MethodSource("Test createLambda") + @ParameterizedTest + fun `Test createLambda`(expected: DynamicCallSiteDesc, actual: DynamicCallSiteDesc) { + assertEquals(expected, actual) + } + + @JvmStatic + fun `Test createLambda`(): List = listOf( + Arguments.of( + DynamicCallSiteDesc.of( + lambdaMetafactoryDesc, + "get", + MethodTypeDesc.of(CD_Supplier, CD_JDABuilder), + MethodTypeDesc.of(ConstantDescs.CD_Object), + MethodHandleDesc.ofMethod( + DirectMethodHandleDesc.Kind.VIRTUAL, + CD_JDABuilder, + "doBuild", + MethodTypeDesc.of(CD_JDA) + ), + MethodTypeDesc.of(ConstantDescs.CD_Object), + ), + createLambda( + interfaceMethod = Supplier<*>::get, + targetType = CD_JDABuilder, + targetMethod = "doBuild", + targetMethodReturnType = CD_JDA, + targetMethodArguments = listOf(), + capturedTypes = listOf(), + isStatic = false + ) + ), + + Arguments.of( + DynamicCallSiteDesc.of( + lambdaMetafactoryDesc, + "run", + MethodTypeDesc.of(CD_Runnable, CD_JDAService, CD_BReadyEvent, CD_IEventManager), + MethodTypeDesc.of(ConstantDescs.CD_void), + MethodHandleDesc.ofMethod( + DirectMethodHandleDesc.Kind.VIRTUAL, + CD_JDAService, + $$"lambda$onReadyEvent$BotCommands$withBuilderSession", + MethodTypeDesc.of(ConstantDescs.CD_void, CD_BReadyEvent, CD_IEventManager) + ), + MethodTypeDesc.of(ConstantDescs.CD_void), + ), + createLambda( + interfaceMethod = Runnable::run, + targetType = CD_JDAService, + targetMethod = $$"lambda$onReadyEvent$BotCommands$withBuilderSession", + targetMethodReturnType = ConstantDescs.CD_void, + targetMethodArguments = listOf(), + capturedTypes = listOf(CD_BReadyEvent, CD_IEventManager), + isStatic = false + ) + ), + + Arguments.of( + DynamicCallSiteDesc.of( + lambdaMetafactoryDesc, + "run", + MethodTypeDesc.of(CD_Runnable, CD_JDAImpl), + MethodTypeDesc.of(ConstantDescs.CD_void), + MethodHandleDesc.ofMethod( + DirectMethodHandleDesc.Kind.VIRTUAL, + CD_JDAImpl, + "doShutdown", + MethodTypeDesc.of(ConstantDescs.CD_void) + ), + MethodTypeDesc.of(ConstantDescs.CD_void), + ), + createLambda( + interfaceMethod = Runnable::run, + targetType = CD_JDAImpl, + targetMethod = "doShutdown", + targetMethodReturnType = ConstantDescs.CD_void, + targetMethodArguments = listOf(), + capturedTypes = listOf(), + isStatic = false + ) + ), + + Arguments.of( + DynamicCallSiteDesc.of( + lambdaMetafactoryDesc, + "run", + MethodTypeDesc.of(CD_Runnable, CD_BContextImpl, CD_Function0), + MethodTypeDesc.of(ConstantDescs.CD_void), + MethodHandleDesc.ofMethod( + DirectMethodHandleDesc.Kind.VIRTUAL, + CD_BContextImpl, + "doScheduleShutdownSignal", + MethodTypeDesc.of(ConstantDescs.CD_void, CD_Function0) + ), + MethodTypeDesc.of(ConstantDescs.CD_void), + ), + createLambda( + interfaceMethod = Runnable::run, + targetType = CD_BContextImpl, + targetMethod = "doScheduleShutdownSignal", + targetMethodReturnType = ConstantDescs.CD_void, + targetMethodArguments = listOf(), + capturedTypes = listOf(CD_Function0), + isStatic = false + ) + ), + ) +} \ No newline at end of file diff --git a/BotCommands-jda-cache/src/test/resources/logback-test.xml b/BotCommands-jda-cache/src/test/resources/logback-test.xml new file mode 100644 index 000000000..4a6e03caa --- /dev/null +++ b/BotCommands-jda-cache/src/test/resources/logback-test.xml @@ -0,0 +1,14 @@ + + + + + %d{HH:mm:ss.SSS} %boldCyan(%-26.-26thread) %boldYellow(%-20.-20logger{0}) %highlight(%-6level) %msg%n%throwable + + + + + + + + + \ No newline at end of file diff --git a/BotCommands-restarter/build.gradle.kts b/BotCommands-restarter/build.gradle.kts new file mode 100644 index 000000000..ff9e401da --- /dev/null +++ b/BotCommands-restarter/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + id("BotCommands-conventions") + id("BotCommands-publish-conventions") +} + +dependencies { + api(projects.botCommands) + + // Logging + implementation(libs.kotlin.logging) +} diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/ImmediateRestartException.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/ImmediateRestartException.kt new file mode 100644 index 000000000..19edb5b80 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/ImmediateRestartException.kt @@ -0,0 +1,29 @@ +package dev.freya02.botcommands.internal.restart + +import java.lang.reflect.InvocationTargetException + +class ImmediateRestartException internal constructor() : RuntimeException("Dummy exception to stop the execution of the first main thread") { + + internal companion object { + internal fun throwAndHandle(): Nothing { + val currentThread = Thread.currentThread() + currentThread.uncaughtExceptionHandler = ExpectedReloadExceptionHandler(currentThread.uncaughtExceptionHandler) + throw ImmediateRestartException() + } + } + + private class ExpectedReloadExceptionHandler(private val delegate: Thread.UncaughtExceptionHandler?) : Thread.UncaughtExceptionHandler { + + override fun uncaughtException(t: Thread, e: Throwable) { + if (e is ImmediateRestartException || (e is InvocationTargetException && e.targetException is ImmediateRestartException)) { + return + } + + if (delegate != null) { + delegate.uncaughtException(t, e) + } else { + e.printStackTrace() + } + } + } +} \ No newline at end of file diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/LeakSafeExecutor.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/LeakSafeExecutor.kt new file mode 100644 index 000000000..b36bbbed6 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/LeakSafeExecutor.kt @@ -0,0 +1,61 @@ +package dev.freya02.botcommands.internal.restart + +import java.util.concurrent.BlockingDeque +import java.util.concurrent.LinkedBlockingDeque +import kotlin.system.exitProcess + +internal class LeakSafeExecutor internal constructor() { + + // As we can only use a Thread once, we put a single LeakSafeThread in a blocking queue, + // then, when a code block runs, a LeakSafeThread is removed from the queue, + // and the LeakSafeThread recreates a new one for the next code block. + // We use a blocking queue to prevent trying to get a LeakSafeThread between the moment it was retrieved and when it'll be added back + private val leakSafeThreads: BlockingDeque = LinkedBlockingDeque() + + init { + leakSafeThreads += LeakSafeThread() + } + + fun callAndWait(callable: () -> V): V = getLeakSafeThread().callAndWait(callable) + + private fun getLeakSafeThread(): LeakSafeThread { + return leakSafeThreads.takeFirst() + } + + /** + * Thread that is created early so not to retain the [RestartClassLoader]. + */ + private inner class LeakSafeThread : Thread() { + + private var callable: (() -> Any?)? = null + + private var result: Any? = null + + init { + isDaemon = false + } + + @Suppress("UNCHECKED_CAST") + fun callAndWait(callable: () -> V): V { + this.callable = callable + start() + try { + join() + return this.result as V + } catch (ex: InterruptedException) { + currentThread().interrupt() + throw IllegalStateException(ex) + } + } + + override fun run() { + try { + this@LeakSafeExecutor.leakSafeThreads.put(LeakSafeThread()) + this.result = this.callable!!.invoke() + } catch (ex: Exception) { + ex.printStackTrace() + exitProcess(1) + } + } + } +} \ No newline at end of file diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/RestartClassLoader.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/RestartClassLoader.kt new file mode 100644 index 000000000..d803b7800 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/RestartClassLoader.kt @@ -0,0 +1,40 @@ +package dev.freya02.botcommands.internal.restart + +import java.net.URL +import java.net.URLClassLoader +import java.util.* + +// STILL SUPER DUPER IMPORTANT TO OVERRIDE SOME STUFF AND DELEGATE +internal class RestartClassLoader internal constructor( + urls: List, + parent: ClassLoader, +) : URLClassLoader(urls.toTypedArray(), parent) { + + override fun getResources(name: String): Enumeration { + return this.parent.getResources(name) + } + + override fun getResource(name: String): URL? { + return findResource(name) ?: super.getResource(name) + } + + override fun findResource(name: String): URL? { + return super.findResource(name) + } + + override fun loadClass(name: String, resolve: Boolean): Class<*> { + return synchronized(getClassLoadingLock(name)) { + val loadedClass = findLoadedClass(name) ?: try { + findClass(name) + } catch (_: ClassNotFoundException) { + Class.forName(name, false, parent) + } + if (resolve) resolveClass(loadedClass) + loadedClass + } + } + + override fun findClass(name: String): Class<*> { + return super.findClass(name) + } +} \ No newline at end of file diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/RestartClassLoaderFull.kt.disabled b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/RestartClassLoaderFull.kt.disabled new file mode 100644 index 000000000..605a0b4ee --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/RestartClassLoaderFull.kt.disabled @@ -0,0 +1,116 @@ +package dev.freya02.botcommands.internal.restart + +import dev.freya02.botcommands.internal.restart.sources.DeletedSourceFile +import dev.freya02.botcommands.internal.restart.sources.SourceDirectories +import dev.freya02.botcommands.internal.restart.sources.SourceFile +import java.io.InputStream +import java.net.URL +import java.net.URLClassLoader +import java.net.URLConnection +import java.net.URLStreamHandler +import java.util.* + +internal class RestartClassLoader internal constructor( + urls: List, + parent: ClassLoader, + private val sourceDirectories: SourceDirectories, +) : URLClassLoader(urls.toTypedArray(), parent) { + + override fun getResources(name: String): Enumeration { + val resources = parent.getResources(name) + val updatedFile = sourceDirectories.getFile(name) + + if (updatedFile != null) { + if (resources.hasMoreElements()) { + resources.nextElement() + } + if (updatedFile is SourceFile) { + return MergedEnumeration(createFileUrl(name, updatedFile), resources) + } + } + + return resources + } + + override fun getResource(name: String): URL? { + val updatedFile = sourceDirectories.getFile(name) + if (updatedFile is DeletedSourceFile) { + return null + } + + return findResource(name) ?: super.getResource(name) + } + + override fun findResource(name: String): URL? { + val updatedFile = sourceDirectories.getFile(name) + ?: return super.findResource(name) + return (updatedFile as? SourceFile)?.let { createFileUrl(name, it) } + } + + override fun loadClass(name: String, resolve: Boolean): Class<*> { + val path = "${name.replace('.', '/')}.class" + val updatedFile = sourceDirectories.getFile(path) + if (updatedFile is DeletedSourceFile) + throw ClassNotFoundException(name) + + return synchronized(getClassLoadingLock(name)) { + val loadedClass = findLoadedClass(name) ?: try { + findClass(name) + } catch (_: ClassNotFoundException) { + Class.forName(name, false, parent) + } + if (resolve) resolveClass(loadedClass) + loadedClass + } + } + + override fun findClass(name: String): Class<*> { + val path = "${name.replace('.', '/')}.class" + val updatedFile = sourceDirectories.getFile(path) + ?: return super.findClass(name) + if (updatedFile is DeletedSourceFile) + throw ClassNotFoundException(name) + + updatedFile as SourceFile + return defineClass(name, updatedFile.bytes, 0, updatedFile.bytes.size) + } + + @Suppress("DEPRECATION") // We target Java 17 but JDK 20 deprecates the URL constructors + private fun createFileUrl(name: String, file: SourceFile): URL { + return URL("reloaded", null, -1, "/$name", ClasspathFileURLStreamHandler(file)) + } + + private class ClasspathFileURLStreamHandler( + private val file: SourceFile, + ) : URLStreamHandler() { + + override fun openConnection(u: URL): URLConnection = Connection(u) + + private inner class Connection(url: URL): URLConnection(url) { + + override fun connect() {} + + override fun getInputStream(): InputStream = file.bytes.inputStream() + + override fun getLastModified(): Long = file.lastModified.toEpochMilli() + + override fun getContentLengthLong(): Long = file.bytes.size.toLong() + } + } + + private class MergedEnumeration(private val first: E, private val rest: Enumeration) : Enumeration { + + private var hasConsumedFirst = false + + override fun hasMoreElements(): Boolean = !hasConsumedFirst || rest.hasMoreElements() + + override fun nextElement(): E? { + if (!hasConsumedFirst) { + hasConsumedFirst = true + return first + } else { + return rest.nextElement() + } + } + } +} \ No newline at end of file diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/RestartListener.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/RestartListener.kt new file mode 100644 index 000000000..4d773905c --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/RestartListener.kt @@ -0,0 +1,5 @@ +package dev.freya02.botcommands.internal.restart + +interface RestartListener { + fun beforeStop() +} \ No newline at end of file diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/Restarter.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/Restarter.kt new file mode 100644 index 000000000..bcdaa5b1c --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/Restarter.kt @@ -0,0 +1,125 @@ +package dev.freya02.botcommands.internal.restart + +import dev.freya02.botcommands.internal.restart.utils.AppClasspath +import io.github.oshai.kotlinlogging.KotlinLogging +import java.net.URL +import java.util.concurrent.locks.Lock +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.thread +import kotlin.concurrent.withLock + +private val logger = KotlinLogging.logger { } + +class Restarter private constructor( + private val args: List, +) { + + private val appClassLoader: ClassLoader + val appClasspathUrls: List + + private val mainClassName: String + + private val uncaughtExceptionHandler: Thread.UncaughtExceptionHandler + + private val stopLock: Lock = ReentrantLock() + private val listeners: MutableList = arrayListOf() + + private val leakSafeExecutor = LeakSafeExecutor() + + init { + val thread = Thread.currentThread() + + appClassLoader = thread.contextClassLoader + appClasspathUrls = AppClasspath.getPaths().map { it.toUri().toURL() } + + mainClassName = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE) + .walk { stream -> stream.filter { it.methodName == "main" }.toList().last() } + .declaringClass.name + + uncaughtExceptionHandler = thread.uncaughtExceptionHandler + } + + fun addListener(listener: RestartListener) { + listeners += listener + } + + private fun initialize(): Nothing { + val throwable = leakSafeExecutor.callAndWait { start() } + if (throwable != null) + throw throwable + ImmediateRestartException.throwAndHandle() + } + + /** + * Runs each [RestartListener.beforeStop] and then starts a new instance of the main class, + * if the new instance fails, the [Throwable] is returned. + */ + fun restart(): Throwable? { + logger.debug { "Restarting application in '$mainClassName'" } + // Do it from the original class loader, so the context is the same as for the initial restart + return leakSafeExecutor.callAndWait { + stop() + start() + } + } + + private fun stop() { + stopLock.withLock { + listeners.forEach { it.beforeStop() } + listeners.clear() + } + // All threads should be stopped at that point + // so the GC should be able to remove all the previous loaded classes + System.gc() + } + + /** + * Starts a new instance of the main class, or returns a [Throwable] if it failed. + */ + private fun start(): Throwable? { + // We use a regular URLClassLoader instead of RestartClassLoaderFull, + // as classpath changes will trigger a restart and thus recreate a new ClassLoader, + // meaning live updating the classes is pointless. + // In contrast, Spring needs their RestartClassLoader because it can override classes remotely, + // but we don't have such a use case. + // However, not using RestartClassLoaderFull, which uses snapshots, has an issue, + // trying to load deleted classes (most likely on shutdown) will fail, + // Spring also has that issue, but it will only happen on classes out of its component scan, + // BC just needs to make sure to at least load the classes on its path too. + val restartClassLoader = RestartClassLoader(appClasspathUrls, appClassLoader) + var error: Throwable? = null + val launchThreads = thread(name = RESTARTED_THREAD_NAME, isDaemon = false, contextClassLoader = restartClassLoader) { + try { + val mainClass = Class.forName(mainClassName, false, restartClassLoader) + val mainMethod = mainClass.getDeclaredMethod("main", Array::class.java) + mainMethod.isAccessible = true + mainMethod.invoke(null, args.toTypedArray()) + } catch (ex: Throwable) { + error = ex + } + } + launchThreads.join() + + return error + } + + companion object { + + const val RESTARTED_THREAD_NAME = "restartedMain" + + private val instanceLock: Lock = ReentrantLock() + lateinit var instance: Restarter + private set + + fun initialize(args: List) { + var newInstance: Restarter? = null + instanceLock.withLock { + if (::instance.isInitialized.not()) { + newInstance = Restarter(args) + instance = newInstance + } + } + newInstance?.initialize() + } + } +} diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/services/RestarterApplicationStartListener.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/services/RestarterApplicationStartListener.kt new file mode 100644 index 000000000..63886c377 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/services/RestarterApplicationStartListener.kt @@ -0,0 +1,16 @@ +package dev.freya02.botcommands.internal.restart.services + +import dev.freya02.botcommands.internal.restart.Restarter +import io.github.freya022.botcommands.api.core.events.ApplicationStartListener +import io.github.freya022.botcommands.api.core.events.BApplicationStartEvent +import io.github.freya022.botcommands.api.core.service.annotations.BService +import io.github.freya022.botcommands.api.core.service.annotations.RequiresDefaultInjection + +@BService +@RequiresDefaultInjection +internal class RestarterApplicationStartListener : ApplicationStartListener { + + override fun onApplicationStart(event: BApplicationStartEvent) { + Restarter.initialize(event.args) + } +} \ No newline at end of file diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/services/RestarterService.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/services/RestarterService.kt new file mode 100644 index 000000000..61572b288 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/services/RestarterService.kt @@ -0,0 +1,27 @@ +package dev.freya02.botcommands.internal.restart.services + +import dev.freya02.botcommands.internal.restart.RestartListener +import dev.freya02.botcommands.internal.restart.Restarter +import dev.freya02.botcommands.internal.restart.watcher.ClasspathWatcher +import io.github.freya022.botcommands.api.core.BContext +import io.github.freya022.botcommands.api.core.config.BRestartConfig +import io.github.freya022.botcommands.api.core.service.annotations.BService +import io.github.freya022.botcommands.api.core.service.annotations.RequiresDefaultInjection + +@BService +@RequiresDefaultInjection +internal class RestarterService internal constructor ( + context: BContext, + config: BRestartConfig, +) { + + init { + Restarter.instance.addListener(object : RestartListener { + override fun beforeStop() { + context.shutdownNow() + context.awaitShutdown(context.config.shutdownTimeout) + } + }) + ClasspathWatcher.initialize(config.restartDelay) + } +} \ No newline at end of file diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceDirectories.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceDirectories.kt new file mode 100644 index 000000000..2dc84c9cf --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceDirectories.kt @@ -0,0 +1,48 @@ +package dev.freya02.botcommands.internal.restart.sources + +import java.nio.file.Path + +internal class SourceDirectories internal constructor() { + private val directories: MutableMap = hashMapOf() + + internal fun getFile(path: String): ISourceFile? { + return directories.firstNotNullOfOrNull { it.value.files[path] } + } + + internal fun setSource(source: SourceDirectory) { + directories[source.directory] = source + } + + internal fun replaceSource(key: Path, directory: SourceDirectory) { + check(key in directories) + + directories[key] = directory + } + + internal fun close() { + directories.values.forEach { it.close() } + } +} + +internal fun SourceDirectories(directories: List, listener: SourceDirectoriesListener): SourceDirectories { + val sourceDirectories = SourceDirectories() + + fun onSourceDirectoryUpdate(directory: Path, sourceFilesFactory: () -> SourceFiles) { + // The command is called when restarting + // so we don't make snapshots before all changes went through + listener.onChange(command = { + val newSourceDirectory = SourceDirectory( + directory, + sourceFilesFactory(), + listener = { onSourceDirectoryUpdate(directory, it) } + ) + sourceDirectories.replaceSource(directory, newSourceDirectory) + }) + } + + directories.forEach { directory -> + sourceDirectories.setSource(SourceDirectory(directory, listener = { onSourceDirectoryUpdate(directory, it) })) + } + + return sourceDirectories +} \ No newline at end of file diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceDirectoriesListener.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceDirectoriesListener.kt new file mode 100644 index 000000000..75b9f721b --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceDirectoriesListener.kt @@ -0,0 +1,7 @@ +package dev.freya02.botcommands.internal.restart.sources + +internal interface SourceDirectoriesListener { + fun onChange(command: () -> Unit) + + fun onCancel() +} \ No newline at end of file diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceDirectory.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceDirectory.kt new file mode 100644 index 000000000..cc5ab38c7 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceDirectory.kt @@ -0,0 +1,90 @@ +package dev.freya02.botcommands.internal.restart.sources + +import dev.freya02.botcommands.internal.restart.utils.walkDirectories +import dev.freya02.botcommands.internal.restart.utils.walkFiles +import io.github.oshai.kotlinlogging.KotlinLogging +import java.nio.file.Path +import java.nio.file.StandardWatchEventKinds.* +import kotlin.concurrent.thread +import kotlin.io.path.* + +private val logger = KotlinLogging.logger { } + +@OptIn(ExperimentalPathApi::class) +internal class SourceDirectory internal constructor( + val directory: Path, + val files: SourceFiles, + private val listener: SourceDirectoryListener, +) { + + private val thread: Thread + + init { + require(directory.isDirectory()) + + logger.trace { "Listening to ${directory.absolutePathString()}" } + + val watchService = directory.fileSystem.newWatchService() + directory.walkDirectories { path, attributes -> + path.register(watchService, ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE) + } + + thread = thread(name = "Classpath watcher of '${directory.fileName}'", isDaemon = true) { + try { + watchService.take() // Wait for a change + } catch (_: InterruptedException) { + return@thread logger.trace { "Interrupted watching ${directory.absolutePathString()}" } + } + watchService.close() + + listener.onChange(sourcesFilesFactory = { + val snapshot = directory.takeSnapshot() + + // Exclude deleted files so they don't count as being deleted again + val deletedPaths = files.withoutDeletes().keys - snapshot.keys + if (deletedPaths.isNotEmpty()) { + logger.info { "Deleted files in ${directory.absolutePathString()}: $deletedPaths" } + return@onChange deletedPaths.associateWith { DeletedSourceFile } + snapshot + } + + // Exclude deleted files so they count as being added back + val addedPaths = snapshot.keys - files.withoutDeletes().keys + if (addedPaths.isNotEmpty()) { + logger.info { "Added files in ${directory.absolutePathString()}: $addedPaths" } + return@onChange files + snapshot + } + + val modifiedFiles = snapshot.keys.filter { key -> + val actual = snapshot[key] ?: error("Key from map is missing a value somehow") + val expected = files[key] ?: error("Expected file is missing, should have been detected as deleted") + + // File was deleted (on the 2nd build for example) and got recreated (on the 3rd build for example) + if (expected is DeletedSourceFile) error("Expected file was registered as deleted, should have been detected as added") + expected as SourceFile + + actual as SourceFile // Assertion + + actual.lastModified != expected.lastModified + } + if (modifiedFiles.isNotEmpty()) { + logger.info { "Timestamp changed in ${directory.absolutePathString()}: $modifiedFiles" } + return@onChange files + snapshot + } + + error("Received a file system event but no changes were detected") + }) + } + } + + internal fun close() { + thread.interrupt() + } +} + +internal fun SourceDirectory(directory: Path, listener: SourceDirectoryListener): SourceDirectory { + return SourceDirectory(directory, directory.takeSnapshot(), listener) +} + +private fun Path.takeSnapshot(): SourceFiles = walkFiles().associate { (it, attrs) -> + it.relativeTo(this).pathString to SourceFile(attrs.lastModifiedTime().toInstant()) +}.let(::SourceFiles) diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceDirectoryListener.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceDirectoryListener.kt new file mode 100644 index 000000000..535021105 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceDirectoryListener.kt @@ -0,0 +1,5 @@ +package dev.freya02.botcommands.internal.restart.sources + +internal fun interface SourceDirectoryListener { + fun onChange(sourcesFilesFactory: () -> SourceFiles) +} \ No newline at end of file diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceFile.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceFile.kt new file mode 100644 index 000000000..648bb579d --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceFile.kt @@ -0,0 +1,15 @@ +package dev.freya02.botcommands.internal.restart.sources + +import java.time.Instant + +internal sealed interface ISourceFile + +internal class SourceFile( + val lastModified: Instant, +) : ISourceFile { + + val bytes: ByteArray + get() = throw UnsupportedOperationException("Class data is no longer retained as RestartClassLoader is not used yet") +} + +internal object DeletedSourceFile : ISourceFile \ No newline at end of file diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceFiles.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceFiles.kt new file mode 100644 index 000000000..2ded12566 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceFiles.kt @@ -0,0 +1,16 @@ +package dev.freya02.botcommands.internal.restart.sources + +internal class SourceFiles internal constructor( + internal val files: Map, +) { + + val keys: Set get() = files.keys + + internal operator fun get(path: String): ISourceFile? = files[path] + + internal fun withoutDeletes(): SourceFiles = SourceFiles(files.filterValues { it !is DeletedSourceFile }) + + internal operator fun plus(other: SourceFiles): SourceFiles = SourceFiles(files + other.files) +} + +internal operator fun Map.plus(other: SourceFiles): SourceFiles = SourceFiles(this + other.files) \ No newline at end of file diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/utils/AppClasspath.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/utils/AppClasspath.kt new file mode 100644 index 000000000..ab4732654 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/utils/AppClasspath.kt @@ -0,0 +1,18 @@ +package dev.freya02.botcommands.internal.restart.utils + +import java.io.File +import java.lang.management.ManagementFactory +import java.nio.file.Path +import kotlin.io.path.Path +import kotlin.io.path.isDirectory + +internal object AppClasspath { + + fun getPaths(): List { + return ManagementFactory.getRuntimeMXBean().classPath + .split(File.pathSeparator) + .map(::Path) + .filter { it.isDirectory() } + .filter { it.endsWith("test-classes") } // TODO add proper configurable filters + } +} \ No newline at end of file diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/utils/NIO.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/utils/NIO.kt new file mode 100644 index 000000000..cbcbb7d88 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/utils/NIO.kt @@ -0,0 +1,66 @@ +package dev.freya02.botcommands.internal.restart.utils + +import java.io.IOException +import java.nio.file.FileVisitResult +import java.nio.file.FileVisitor +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.BasicFileAttributes + +// Optimization of Path#walk, cuts CPU usage by 4 +// mostly by eliminating duplicate calls to file attributes +internal fun Path.walkFiles(): List> { + return buildList { + Files.walkFileTree(this@walkFiles, object : FileVisitor { + override fun preVisitDirectory( + dir: Path, + attrs: BasicFileAttributes + ): FileVisitResult = FileVisitResult.CONTINUE + + override fun visitFile( + file: Path, + attrs: BasicFileAttributes + ): FileVisitResult { + add(file to attrs) + return FileVisitResult.CONTINUE + } + + override fun visitFileFailed( + file: Path, + exc: IOException + ): FileVisitResult = FileVisitResult.CONTINUE + + override fun postVisitDirectory( + dir: Path, + exc: IOException? + ): FileVisitResult = FileVisitResult.CONTINUE + }) + } +} + +internal fun Path.walkDirectories(block: (Path, BasicFileAttributes) -> Unit) { + Files.walkFileTree(this@walkDirectories, object : FileVisitor { + override fun preVisitDirectory( + dir: Path, + attrs: BasicFileAttributes + ): FileVisitResult { + block(dir, attrs) + return FileVisitResult.CONTINUE + } + + override fun visitFile( + file: Path, + attrs: BasicFileAttributes + ): FileVisitResult = FileVisitResult.CONTINUE + + override fun visitFileFailed( + file: Path, + exc: IOException + ): FileVisitResult = FileVisitResult.CONTINUE + + override fun postVisitDirectory( + dir: Path, + exc: IOException? + ): FileVisitResult = FileVisitResult.CONTINUE + }) +} \ No newline at end of file diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/watcher/ClasspathListener.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/watcher/ClasspathListener.kt new file mode 100644 index 000000000..6805bd4d8 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/watcher/ClasspathListener.kt @@ -0,0 +1,42 @@ +package dev.freya02.botcommands.internal.restart.watcher + +import dev.freya02.botcommands.internal.restart.Restarter +import dev.freya02.botcommands.internal.restart.sources.SourceDirectoriesListener +import io.github.oshai.kotlinlogging.KotlinLogging +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit +import kotlin.time.Duration + +private val logger = KotlinLogging.logger { } + +internal class ClasspathListener internal constructor( + private val delay: Duration +) : SourceDirectoriesListener { + + private val scheduler = Executors.newSingleThreadScheduledExecutor() + private lateinit var scheduledRestart: ScheduledFuture<*> + + private val commands: MutableList<() -> Unit> = arrayListOf() + + override fun onChange(command: () -> Unit) { + commands += command + if (::scheduledRestart.isInitialized) scheduledRestart.cancel(false) + + scheduledRestart = scheduler.schedule({ + commands.forEach { it.invoke() } + commands.clear() + + try { + Restarter.instance.restart() + } catch (e: Exception) { + logger.error(e) { "Restart failed, waiting for the next build" } + } + scheduler.shutdown() + }, delay.inWholeMilliseconds, TimeUnit.MILLISECONDS) + } + + override fun onCancel() { + scheduler.shutdownNow() + } +} \ No newline at end of file diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/watcher/ClasspathWatcher.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/watcher/ClasspathWatcher.kt new file mode 100644 index 000000000..718411643 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/watcher/ClasspathWatcher.kt @@ -0,0 +1,182 @@ +package dev.freya02.botcommands.internal.restart.watcher + +import dev.freya02.botcommands.internal.restart.Restarter +import dev.freya02.botcommands.internal.restart.sources.DeletedSourceFile +import dev.freya02.botcommands.internal.restart.sources.SourceFile +import dev.freya02.botcommands.internal.restart.sources.SourceFiles +import dev.freya02.botcommands.internal.restart.sources.plus +import dev.freya02.botcommands.internal.restart.utils.AppClasspath +import dev.freya02.botcommands.internal.restart.utils.walkDirectories +import dev.freya02.botcommands.internal.restart.utils.walkFiles +import io.github.freya022.botcommands.api.core.utils.joinAsList +import io.github.oshai.kotlinlogging.KotlinLogging +import java.nio.file.FileSystems +import java.nio.file.Path +import java.nio.file.StandardWatchEventKinds.* +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.thread +import kotlin.concurrent.withLock +import kotlin.io.path.absolutePathString +import kotlin.io.path.isDirectory +import kotlin.io.path.pathString +import kotlin.io.path.relativeTo +import kotlin.time.Duration + +private val logger = KotlinLogging.logger { } + +// Lightweight, singleton version of [[SourceDirectories]] + [[ClasspathListener]] +internal class ClasspathWatcher private constructor( + private var settings: Settings?, // null = no instance registered = no restart can be scheduled +) { + + private val scheduler = Executors.newSingleThreadScheduledExecutor() + private lateinit var restartFuture: ScheduledFuture<*> + + private val watchService = FileSystems.getDefault().newWatchService() + private val registeredDirectories: MutableSet = ConcurrentHashMap.newKeySet() + private val snapshots: MutableMap = hashMapOf() + + init { + AppClasspath.getPaths().forEach { classRoot -> + require(classRoot.isDirectory()) + + logger.trace { "Creating snapshot of ${classRoot.absolutePathString()}" } + snapshots[classRoot] = classRoot.takeSnapshot() + + logger.trace { "Listening to ${classRoot.absolutePathString()}" } + registerDirectories(classRoot) + } + + thread(name = "Classpath watcher", isDaemon = true) { + while (true) { + val key = try { + watchService.take() // Wait for a change + } catch (_: InterruptedException) { + return@thread logger.trace { "Interrupted watching classpath" } + } + val pollEvents = key.pollEvents() + if (pollEvents.isNotEmpty()) { + logger.trace { + val affectedList = pollEvents.joinAsList { "${it.kind()}: ${it.context()}" } + "Affected files:\n$affectedList" + } + } else { + // Seems to be empty when a directory gets deleted + // The next watch key *should* be an ENTRY_DELETE of that directory + continue + } + if (!key.reset()) { + logger.warn { "${key.watchable()} is no longer valid" } + continue + } + + scheduleRestart() + } + } + } + + private fun scheduleRestart() { + val settings = settings ?: return // Don't schedule a restart until an instance has registered + if (::restartFuture.isInitialized) restartFuture.cancel(false) + restartFuture = scheduler.schedule(::tryRestart, settings.restartDelay.inWholeMilliseconds, TimeUnit.MILLISECONDS) + } + + private fun tryRestart() { + // Can't set to null after restarting, + // as the restart function only returns after the main method ran + val settings = settings ?: return + try { + logger.debug { "Attempting to restart" } + this.settings = null // Wait until the next instance has given its settings + compareSnapshots() + snapshots.keys.forEach { registerDirectories(it) } + Restarter.instance.restart() + } catch (e: Exception) { + logger.error(e) { "Restart failed, waiting for the next build" } + this.settings = settings // Reuse the old settings to reschedule a new restart + } + } + + private fun compareSnapshots() { + val hasChanges = snapshots.any { (directory, files) -> + val snapshot = directory.takeSnapshot() + + // Exclude deleted files so they don't count as being deleted again + val deletedPaths = files.withoutDeletes().keys - snapshot.keys + if (deletedPaths.isNotEmpty()) { + logger.info { "${deletedPaths.size} files were deleted in ${directory.absolutePathString()}: $deletedPaths" } + snapshots[directory] = deletedPaths.associateWith { DeletedSourceFile } + snapshot + // So we can re-register them in case they are recreated + registeredDirectories.removeAll(deletedPaths.map { directory.resolve(it) }) + return@any true + } + + // Exclude deleted files so they count as being added back + val addedPaths = snapshot.keys - files.withoutDeletes().keys + if (addedPaths.isNotEmpty()) { + logger.info { "${addedPaths.size} files were added in ${directory.absolutePathString()}: $addedPaths" } + snapshots[directory] = files + snapshot + return@any true + } + + val modifiedFiles = snapshot.keys.filter { key -> + val actual = snapshot[key] ?: error("Key from map is missing a value somehow") + val expected = files[key] ?: error("Expected file is missing, should have been detected as deleted") + + // File was deleted (on the 2nd build for example) and got recreated (on the 3rd build for example) + if (expected is DeletedSourceFile) error("Expected file was registered as deleted, should have been detected as added") + expected as SourceFile + + actual as SourceFile // Assertion + + actual.lastModified != expected.lastModified + } + if (modifiedFiles.isNotEmpty()) { + logger.info { "${modifiedFiles.size} files were modified in ${directory.absolutePathString()}: $modifiedFiles" } + snapshots[directory] = files + snapshot + return@any true + } + + false + } + + if (!hasChanges) + error("Received a file system event but no changes were detected") + } + + private fun registerDirectories(directory: Path) { + directory.walkDirectories { path, attributes -> + if (registeredDirectories.add(path)) + path.register(watchService, ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE) + } + } + + private class Settings( + val restartDelay: Duration, + ) + + internal companion object { + private val instanceLock = ReentrantLock() + internal lateinit var instance: ClasspathWatcher + private set + + internal fun initialize(restartDelay: Duration) { + instanceLock.withLock { + val settings = Settings(restartDelay) + if (::instance.isInitialized.not()) { + instance = ClasspathWatcher(settings) + } else { + instance.settings = settings + } + } + } + } +} + +private fun Path.takeSnapshot(): SourceFiles = walkFiles().associate { (it, attrs) -> + it.relativeTo(this).pathString to SourceFile(attrs.lastModifiedTime().toInstant()) +}.let(::SourceFiles) \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index b13e33fb7..bcbe55fa5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -4,3 +4,5 @@ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") include(":spring-properties-processor") include(":BotCommands-spring") +include(":BotCommands-restarter") +include(":BotCommands-jda-cache") From b58297eab774dd1114f49ada2a866fb622a2e9a6 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Thu, 26 Jun 2025 21:52:55 +0200 Subject: [PATCH 31/45] Rename DefaultShutdownHook to BCShutdownHook --- .../freya022/botcommands/internal/core/BContextImpl.kt | 4 ++-- .../service/{DefaultShutdownHook.kt => BCShutdownHook.kt} | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) rename src/main/kotlin/io/github/freya022/botcommands/internal/core/service/{DefaultShutdownHook.kt => BCShutdownHook.kt} (93%) diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt index 5f683804d..723fc6e5e 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt @@ -156,7 +156,7 @@ internal class BContextImpl internal constructor( override fun shutdown() { if (status == Status.SHUTTING_DOWN || status == Status.SHUTDOWN) return - // Shutdown hook will be removed by [[DefaultShutdownHook]] if we use the built-in DI + // Shutdown hook will be removed by [[BCShutdownHook]] if we use the built-in DI runBlocking { setStatus(Status.SHUTTING_DOWN) } scheduleShutdownSignal(afterShutdownSignal = { @@ -171,7 +171,7 @@ internal class BContextImpl internal constructor( // Do not call shutdown(), more precisely do not call scheduleShutdownSignal() twice if (status == Status.SHUTTING_DOWN || status == Status.SHUTDOWN) return - // Shutdown hook will be removed by [[DefaultShutdownHook]] if we use the built-in DI + // Shutdown hook will be removed by [[BCShutdownHook]] if we use the built-in DI runBlocking { setStatus(Status.SHUTTING_DOWN) } scheduleShutdownSignal(afterShutdownSignal = { diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/DefaultShutdownHook.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCShutdownHook.kt similarity index 93% rename from src/main/kotlin/io/github/freya022/botcommands/internal/core/service/DefaultShutdownHook.kt rename to src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCShutdownHook.kt index 16496ee3d..581da8840 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/DefaultShutdownHook.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCShutdownHook.kt @@ -15,9 +15,9 @@ import io.github.freya022.botcommands.api.core.service.getService @Lazy @BService -@ConditionalService(DefaultShutdownHook.ActivationCondition::class) +@ConditionalService(BCShutdownHook.ActivationCondition::class) @RequiresDefaultInjection -internal class DefaultShutdownHook internal constructor( +internal class BCShutdownHook internal constructor( context: BContext, ) { @@ -48,4 +48,4 @@ internal class DefaultShutdownHook internal constructor( return null } } -} \ No newline at end of file +} From 1c9864e22abbe6ed9f6a886261b8ca9fbd84d453 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Thu, 26 Jun 2025 22:25:06 +0200 Subject: [PATCH 32/45] Ignore IllegalStateException from Runtime#removeShutdownHook --- .../botcommands/internal/core/service/BCShutdownHook.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCShutdownHook.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCShutdownHook.kt index 581da8840..3ad3fb612 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCShutdownHook.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCShutdownHook.kt @@ -31,7 +31,11 @@ internal class BCShutdownHook internal constructor( @BEventListener internal fun onShuttingDown(event: BStatusChangeEvent) { if (event.newStatus == BContext.Status.SHUTTING_DOWN) { - Runtime.getRuntime().removeShutdownHook(hook) + try { + Runtime.getRuntime().removeShutdownHook(hook) + } catch (_: IllegalStateException) { + // + } } } From b4a5694e08ce3853369cc3650785da11b3665d4e Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Thu, 26 Jun 2025 22:37:53 +0200 Subject: [PATCH 33/45] Restarter: Add properties to exclude directories from restarter classpath --- .../botcommands/internal/restart/Restarter.kt | 2 +- .../internal/restart/utils/AppClasspath.kt | 50 +++++++++++++++++-- .../restart/watcher/ClasspathWatcher.kt | 4 +- .../META-INF/BotCommands-restarter.properties | 13 +++++ 4 files changed, 62 insertions(+), 7 deletions(-) create mode 100644 BotCommands-restarter/src/main/resources/META-INF/BotCommands-restarter.properties diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/Restarter.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/Restarter.kt index bcdaa5b1c..e60d6db87 100644 --- a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/Restarter.kt +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/Restarter.kt @@ -30,7 +30,7 @@ class Restarter private constructor( val thread = Thread.currentThread() appClassLoader = thread.contextClassLoader - appClasspathUrls = AppClasspath.getPaths().map { it.toUri().toURL() } + appClasspathUrls = AppClasspath.paths.map { it.toUri().toURL() } mainClassName = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE) .walk { stream -> stream.filter { it.methodName == "main" }.toList().last() } diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/utils/AppClasspath.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/utils/AppClasspath.kt index ab4732654..1375d20f2 100644 --- a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/utils/AppClasspath.kt +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/utils/AppClasspath.kt @@ -1,18 +1,60 @@ package dev.freya02.botcommands.internal.restart.utils +import io.github.oshai.kotlinlogging.KotlinLogging import java.io.File import java.lang.management.ManagementFactory import java.nio.file.Path +import java.util.* import kotlin.io.path.Path import kotlin.io.path.isDirectory +private val logger = KotlinLogging.logger { } + internal object AppClasspath { - fun getPaths(): List { - return ManagementFactory.getRuntimeMXBean().classPath + val paths: List + + init { + val resources = Thread.currentThread().contextClassLoader.getResources("META-INF/BotCommands-restarter.properties") + + val excludePatterns = buildSet { + resources.iterator().forEach { url -> + val properties = url.openStream().use { inputStream -> + val prop = Properties() + prop.load(inputStream) + prop + } + + // Load "restart.exclude.[patternName]=[pattern]" + properties.forEach { key, value -> + if (key !is String) return@forEach + if (value !is String) return@forEach + + val patternName = key.substringAfter("restart.exclude.", missingDelimiterValue = "") + if (patternName.isNotBlank() && value.isNotBlank()) { + add(value.toRegex()) + } + } + } + } + + logger.debug { "Restart classpath exclude patterns: $excludePatterns" } + + val (includedPaths, excludedPaths) = ManagementFactory.getRuntimeMXBean().classPath .split(File.pathSeparator) .map(::Path) .filter { it.isDirectory() } - .filter { it.endsWith("test-classes") } // TODO add proper configurable filters + .partition { path -> + val uri = path.toUri().toString() + if (excludePatterns.any { it.containsMatchIn(uri) }) + return@partition false // Exclude + + true // Include + } + + logger.info { "Restart classpath includes (+ JARs) $includedPaths" } + logger.info { "Restart classpath excludes $excludedPaths" } + + paths = includedPaths } -} \ No newline at end of file +} diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/watcher/ClasspathWatcher.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/watcher/ClasspathWatcher.kt index 718411643..c024e09f0 100644 --- a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/watcher/ClasspathWatcher.kt +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/watcher/ClasspathWatcher.kt @@ -41,7 +41,7 @@ internal class ClasspathWatcher private constructor( private val snapshots: MutableMap = hashMapOf() init { - AppClasspath.getPaths().forEach { classRoot -> + AppClasspath.paths.forEach { classRoot -> require(classRoot.isDirectory()) logger.trace { "Creating snapshot of ${classRoot.absolutePathString()}" } @@ -179,4 +179,4 @@ internal class ClasspathWatcher private constructor( private fun Path.takeSnapshot(): SourceFiles = walkFiles().associate { (it, attrs) -> it.relativeTo(this).pathString to SourceFile(attrs.lastModifiedTime().toInstant()) -}.let(::SourceFiles) \ No newline at end of file +}.let(::SourceFiles) diff --git a/BotCommands-restarter/src/main/resources/META-INF/BotCommands-restarter.properties b/BotCommands-restarter/src/main/resources/META-INF/BotCommands-restarter.properties new file mode 100644 index 000000000..4f3cfc990 --- /dev/null +++ b/BotCommands-restarter/src/main/resources/META-INF/BotCommands-restarter.properties @@ -0,0 +1,13 @@ +# This file is for BotCommands's own tests +# It is in the production resources so it can be applied regardless of in which module it is used +# This principally excludes source sets which are used by the restarter, to avoid classpath issues + +restart.exclude.jda-cache-prod=BotCommands-jda-cache/build/classes/(?:kotlin|java)/main +restart.exclude.jda-cache-prod-res=BotCommands-jda-cache/build/resources/main + +restart.exclude.restarter-prod=BotCommands-restarter/build/classes/(?:kotlin|java)/main +restart.exclude.restarter-prod-res=BotCommands-restarter/build/resources/main + +# Have to use a negative lookbehind as the main module doesn't have a dedicated directory +restart.exclude.core-prod=(? Date: Fri, 27 Jun 2025 12:19:38 +0200 Subject: [PATCH 34/45] Restarter: Handle exception returned by Restarter#restart --- .../botcommands/internal/restart/watcher/ClasspathWatcher.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/watcher/ClasspathWatcher.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/watcher/ClasspathWatcher.kt index c024e09f0..5c44d3ac4 100644 --- a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/watcher/ClasspathWatcher.kt +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/watcher/ClasspathWatcher.kt @@ -94,7 +94,9 @@ internal class ClasspathWatcher private constructor( this.settings = null // Wait until the next instance has given its settings compareSnapshots() snapshots.keys.forEach { registerDirectories(it) } - Restarter.instance.restart() + + val exception = Restarter.instance.restart() + if (exception != null) throw exception } catch (e: Exception) { logger.error(e) { "Restart failed, waiting for the next build" } this.settings = settings // Reuse the old settings to reschedule a new restart From 5744943ba6ace425af8f0ac1514b372534a15322 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Fri, 27 Jun 2025 13:06:32 +0200 Subject: [PATCH 35/45] Restarter: Await for an instance to attach before scheduling a restart When the filesystem changes while an instance is being restarted (slow builds/too short delay), awaiting the new instance allows restarting as soon as the framework is in a state where it can shut down properly --- .../restart/watcher/ClasspathWatcher.kt | 74 +++++++++++++++---- 1 file changed, 60 insertions(+), 14 deletions(-) diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/watcher/ClasspathWatcher.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/watcher/ClasspathWatcher.kt index 5c44d3ac4..25d456052 100644 --- a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/watcher/ClasspathWatcher.kt +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/watcher/ClasspathWatcher.kt @@ -30,9 +30,11 @@ private val logger = KotlinLogging.logger { } // Lightweight, singleton version of [[SourceDirectories]] + [[ClasspathListener]] internal class ClasspathWatcher private constructor( - private var settings: Settings?, // null = no instance registered = no restart can be scheduled + settings: Settings, ) { + private val settingsHolder = SettingsHolder(settings) + private val scheduler = Executors.newSingleThreadScheduledExecutor() private lateinit var restartFuture: ScheduledFuture<*> @@ -74,24 +76,43 @@ internal class ClasspathWatcher private constructor( continue } - scheduleRestart() + // Await for an instance to attach before scheduling a restart + // When the filesystem changes while an instance is being restarted (slow builds), + // awaiting the new instance allows restarting + // as soon as the framework is in a state where it can shut down properly + val settings = settingsHolder.getOrAwait() + if (::restartFuture.isInitialized) restartFuture.cancel(false) + restartFuture = scheduler.schedule(::tryRestart, settings.restartDelay.inWholeMilliseconds, TimeUnit.MILLISECONDS) } } } - private fun scheduleRestart() { - val settings = settings ?: return // Don't schedule a restart until an instance has registered - if (::restartFuture.isInitialized) restartFuture.cancel(false) - restartFuture = scheduler.schedule(::tryRestart, settings.restartDelay.inWholeMilliseconds, TimeUnit.MILLISECONDS) - } - + /** + * Tries to restart immediately, if no instance is registered (i.e., ready for restarts), + * this will wait until one is. + * + * When the restart is attempted, further restart attempts will wait for the current one to finish, + * then, classpath content is checked for changes, throwing if there were none. + * + * Finally, new directories will be watched and the app restarts. + * + * Any exception thrown are caught and will cause more classpath changes to be awaited for a new restart attempt + */ private fun tryRestart() { - // Can't set to null after restarting, - // as the restart function only returns after the main method ran - val settings = settings ?: return + // I believe this should not happen as this method is always single-threaded, + // and only this method can clear the settings, + // but just in case... + val settings = settingsHolder.getOrNull() ?: run { + logger.warn { "Restart was scheduled but instance was unregistered after being scheduled, awaiting new instance" } + settingsHolder.getOrAwait() + } try { logger.debug { "Attempting to restart" } - this.settings = null // Wait until the next instance has given its settings + + // Clear the settings since we are in the process of restarting, + // absent settings prevents further restart attempts while this one hasn't completed. + settingsHolder.clear() + compareSnapshots() snapshots.keys.forEach { registerDirectories(it) } @@ -99,7 +120,7 @@ internal class ClasspathWatcher private constructor( if (exception != null) throw exception } catch (e: Exception) { logger.error(e) { "Restart failed, waiting for the next build" } - this.settings = settings // Reuse the old settings to reschedule a new restart + settingsHolder.set(settings) // Reuse the old settings to reschedule a new restart } } @@ -157,6 +178,31 @@ internal class ClasspathWatcher private constructor( } } + private class SettingsHolder( + settings: Settings, + ) { + // null = no instance registered = no restart can be scheduled + private var settings: Settings? = settings + + private val lock = ReentrantLock() + private val condition = lock.newCondition() + + fun set(settings: Settings) = lock.withLock { + this.settings = settings + condition.signalAll() + } + + fun clear() = lock.withLock { settings = null } + + fun getOrNull(): Settings? = lock.withLock { settings } + + fun getOrAwait(): Settings = lock.withLock { + settings?.let { return it } + condition.await() + return settings!! + } + } + private class Settings( val restartDelay: Duration, ) @@ -172,7 +218,7 @@ internal class ClasspathWatcher private constructor( if (::instance.isInitialized.not()) { instance = ClasspathWatcher(settings) } else { - instance.settings = settings + instance.settingsHolder.set(settings) } } } From d64048499725698a128cbe8938bad755168bd55e Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Fri, 27 Jun 2025 16:21:59 +0200 Subject: [PATCH 36/45] Restarter: Register on PostLoadEvent --- .../restart/services/RestarterService.kt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/services/RestarterService.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/services/RestarterService.kt index 61572b288..60cbda70e 100644 --- a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/services/RestarterService.kt +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/services/RestarterService.kt @@ -3,19 +3,19 @@ package dev.freya02.botcommands.internal.restart.services import dev.freya02.botcommands.internal.restart.RestartListener import dev.freya02.botcommands.internal.restart.Restarter import dev.freya02.botcommands.internal.restart.watcher.ClasspathWatcher -import io.github.freya022.botcommands.api.core.BContext -import io.github.freya022.botcommands.api.core.config.BRestartConfig +import io.github.freya022.botcommands.api.core.annotations.BEventListener +import io.github.freya022.botcommands.api.core.events.PostLoadEvent import io.github.freya022.botcommands.api.core.service.annotations.BService import io.github.freya022.botcommands.api.core.service.annotations.RequiresDefaultInjection @BService @RequiresDefaultInjection -internal class RestarterService internal constructor ( - context: BContext, - config: BRestartConfig, -) { +internal class RestarterService { - init { + @BEventListener + fun onPostLoad(event: PostLoadEvent) { + val context = event.context + val config = context.restartConfig Restarter.instance.addListener(object : RestartListener { override fun beforeStop() { context.shutdownNow() @@ -24,4 +24,4 @@ internal class RestarterService internal constructor ( }) ClasspathWatcher.initialize(config.restartDelay) } -} \ No newline at end of file +} From 1ad972c729642a12b84cdb15384aaee459ce7325 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Fri, 27 Jun 2025 16:30:18 +0200 Subject: [PATCH 37/45] Get BContext in class constructor for SpringJDAShutdownHandler To avoid getting beans while it is closing --- .../internal/core/SpringJDAShutdownHandler.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/SpringJDAShutdownHandler.kt b/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/SpringJDAShutdownHandler.kt index 60f23a561..f1e2b3f3f 100644 --- a/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/SpringJDAShutdownHandler.kt +++ b/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/SpringJDAShutdownHandler.kt @@ -1,7 +1,6 @@ package io.github.freya022.botcommands.internal.core import io.github.freya022.botcommands.api.core.BContext -import org.springframework.beans.factory.getBean import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.context.event.ContextClosedEvent import org.springframework.context.event.EventListener @@ -9,12 +8,13 @@ import org.springframework.stereotype.Component @Component @ConditionalOnProperty(value = ["spring.devtools.restart.enabled", "jda.devtools.enabled"], havingValue = "true", matchIfMissing = true) -internal class SpringJDAShutdownHandler { +internal class SpringJDAShutdownHandler( + private val context: BContext +) { - @EventListener - internal fun onContextClosed(event: ContextClosedEvent) { - val context = event.applicationContext.getBean() + @EventListener(ContextClosedEvent::class) + internal fun onContextClosed() { context.shutdownNow() context.awaitShutdown(context.config.shutdownTimeout) } -} \ No newline at end of file +} From eed7a8bf39ea15556c84a134655e8a28c153bf49 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Fri, 27 Jun 2025 16:35:06 +0200 Subject: [PATCH 38/45] Remove BConfig#shutdownTimeout Not breaking as this didn't exist earlier --- .../internal/restart/services/RestarterService.kt | 1 - .../internal/core/SpringJDAShutdownHandler.kt | 1 - .../internal/core/config/BotCommandsConfigurations.kt | 8 -------- .../freya022/botcommands/api/core/config/BConfig.kt | 10 ---------- 4 files changed, 20 deletions(-) diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/services/RestarterService.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/services/RestarterService.kt index 60cbda70e..5fe838875 100644 --- a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/services/RestarterService.kt +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/services/RestarterService.kt @@ -19,7 +19,6 @@ internal class RestarterService { Restarter.instance.addListener(object : RestartListener { override fun beforeStop() { context.shutdownNow() - context.awaitShutdown(context.config.shutdownTimeout) } }) ClasspathWatcher.initialize(config.restartDelay) diff --git a/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/SpringJDAShutdownHandler.kt b/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/SpringJDAShutdownHandler.kt index f1e2b3f3f..9109c361b 100644 --- a/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/SpringJDAShutdownHandler.kt +++ b/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/SpringJDAShutdownHandler.kt @@ -15,6 +15,5 @@ internal class SpringJDAShutdownHandler( @EventListener(ContextClosedEvent::class) internal fun onContextClosed() { context.shutdownNow() - context.awaitShutdown(context.config.shutdownTimeout) } } diff --git a/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/BotCommandsConfigurations.kt b/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/BotCommandsConfigurations.kt index 7466d07b5..df57f1fb4 100644 --- a/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/BotCommandsConfigurations.kt +++ b/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/BotCommandsConfigurations.kt @@ -13,7 +13,6 @@ import org.springframework.boot.context.properties.bind.Name import java.time.Duration as JavaDuration import kotlin.io.path.Path import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds import kotlin.time.toKotlinDuration @ConfigurationProperties(prefix = "botcommands.core", ignoreUnknownFields = false) @@ -27,11 +26,8 @@ internal class BotCommandsCoreConfiguration( override val ignoredEventIntents: Set> = emptySet(), override val ignoreRestRateLimiter: Boolean = false, override val enableShutdownHook: Boolean = true, - shutdownTimeout: JavaDuration = JavaDuration.ofSeconds(10), ) : BConfig { - override val shutdownTimeout = shutdownTimeout.toKotlinDuration() - override val args: Nothing get() = unusable() override val classGraphProcessors: Nothing get() = unusable() override val serviceConfig: Nothing get() = unusable() @@ -56,10 +52,6 @@ internal fun BConfigBuilder.applyConfig(configuration: BotCommandsCoreConfigurat ignoredIntents += configuration.ignoredIntents ignoredEventIntents += configuration.ignoredEventIntents ignoreRestRateLimiter = configuration.ignoreRestRateLimiter - // If the new property has its default value, try to take the deprecated one - @Suppress("DEPRECATION") - shutdownTimeout = configuration.shutdownTimeout.takeIf { it != 10.seconds } - ?: jdaConfiguration.devTools.shutdownTimeout } @ConfigurationProperties(prefix = "botcommands.database", ignoreUnknownFields = false) diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BConfig.kt b/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BConfig.kt index 2e0a05670..6863e4d7f 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BConfig.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BConfig.kt @@ -22,8 +22,6 @@ import net.dv8tion.jda.api.requests.GatewayIntent import net.dv8tion.jda.api.requests.RestRateLimiter import net.dv8tion.jda.api.utils.messages.MessageCreateData import org.intellij.lang.annotations.Language -import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds @InjectedService interface BConfig { @@ -123,11 +121,6 @@ interface BConfig { @ConfigurationValue("botcommands.core.enableShutdownHook", defaultValue = "false") val enableShutdownHook: Boolean - // TODO java duration - // TODO this will apply only to hot restarts, move it to a BHotRestartConfig prob - @ConfigurationValue("botcommands.core.shutdownTimeout", type = "java.time.Duration", defaultValue = "10s") - val shutdownTimeout: Duration - val serviceConfig: BServiceConfig val databaseConfig: BDatabaseConfig val localizationConfig: BLocalizationConfig @@ -164,8 +157,6 @@ class BConfigBuilder( override var enableShutdownHook: Boolean = true - override var shutdownTimeout: Duration = 10.seconds - override val serviceConfig = BServiceConfigBuilder() override val databaseConfig = BDatabaseConfigBuilder() override val localizationConfig = BLocalizationConfigBuilder() @@ -324,7 +315,6 @@ class BConfigBuilder( override val ignoreRestRateLimiter = this@BConfigBuilder.ignoreRestRateLimiter override val classGraphProcessors = this@BConfigBuilder.classGraphProcessors.toImmutableList() override val enableShutdownHook = this@BConfigBuilder.enableShutdownHook - override val shutdownTimeout = this@BConfigBuilder.shutdownTimeout override val serviceConfig = this@BConfigBuilder.serviceConfig.build() override val databaseConfig = this@BConfigBuilder.databaseConfig.build() override val localizationConfig = this@BConfigBuilder.localizationConfig.build() From a74d850edc17970ca9f57a6daa34de9eab87fad3 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Fri, 27 Jun 2025 16:36:08 +0200 Subject: [PATCH 39/45] Mark JDAConfiguration.DevTools#shutdownTimeout for removal The Spring shutdown listener already doesn't wait for full shutdown --- .../botcommands/api/core/config/JDAConfiguration.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/api/core/config/JDAConfiguration.kt b/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/api/core/config/JDAConfiguration.kt index 8a0d92d47..6a05bd10b 100644 --- a/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/api/core/config/JDAConfiguration.kt +++ b/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/api/core/config/JDAConfiguration.kt @@ -61,9 +61,9 @@ class JDAConfiguration internal constructor( * Time to wait until JDA needs to be forcefully shut down, * in other words, this is the allowed time for a graceful shutdown. */ - @Deprecated("Replaced with botcommands.core.shutdownTimeout") - @DeprecatedValue("Replaced with botcommands.core.shutdownTimeout", replacement = "botcommands.core.shutdownTimeout") + @Deprecated("For removal") + @DeprecatedValue("For removal") @ConfigurationValue("jda.devtools.shutdownTimeout", type = "java.time.Duration", defaultValue = "10s") val shutdownTimeout: Duration = shutdownTimeout.toKotlinDuration() } -} \ No newline at end of file +} From 34769b0dcf03bf10dbfb40852fd0503b165fca38 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Sun, 29 Jun 2025 17:23:58 +0200 Subject: [PATCH 40/45] JDA cache: Add test bot --- BotCommands-jda-cache/build.gradle.kts | 23 ++++++++++++++++ .../botcommands/restart/jda/cache/Bot.kt | 27 +++++++++++++++++++ .../botcommands/restart/jda/cache/Config.kt | 24 +++++++++++++++++ .../botcommands/restart/jda/cache/Main.kt | 19 +++++++++++++ .../restart/jda/cache/SlashTest.kt | 16 +++++++++++ 5 files changed, 109 insertions(+) create mode 100644 BotCommands-jda-cache/src/testBot/kotlin/dev/freya02/botcommands/restart/jda/cache/Bot.kt create mode 100644 BotCommands-jda-cache/src/testBot/kotlin/dev/freya02/botcommands/restart/jda/cache/Config.kt create mode 100644 BotCommands-jda-cache/src/testBot/kotlin/dev/freya02/botcommands/restart/jda/cache/Main.kt create mode 100644 BotCommands-jda-cache/src/testBot/kotlin/dev/freya02/botcommands/restart/jda/cache/SlashTest.kt diff --git a/BotCommands-jda-cache/build.gradle.kts b/BotCommands-jda-cache/build.gradle.kts index cc14b597c..e6c08a9ce 100644 --- a/BotCommands-jda-cache/build.gradle.kts +++ b/BotCommands-jda-cache/build.gradle.kts @@ -13,11 +13,34 @@ dependencies { // -------------------- TEST DEPENDENCIES -------------------- + testImplementation(projects.botCommandsRestarter) testImplementation(libs.mockk) testImplementation(libs.bytebuddy) testImplementation(libs.logback.classic) } +fun registerSourceSet(name: String, extendsTestDependencies: Boolean) { + sourceSets { + register(name) { + compileClasspath += sourceSets.main.get().output + runtimeClasspath += sourceSets.main.get().output + } + } + + configurations["${name}Api"].extendsFrom(configurations["api"]) + configurations["${name}Implementation"].extendsFrom(configurations["implementation"]) + configurations["${name}CompileOnly"].extendsFrom(configurations["compileOnly"]) + + if (extendsTestDependencies) { + configurations["${name}Api"].extendsFrom(configurations["testApi"]) + configurations["${name}Implementation"].extendsFrom(configurations["testImplementation"]) + configurations["${name}CompileOnly"].extendsFrom(configurations["testCompileOnly"]) + } +} + +// Register other source sets +registerSourceSet(name = "testBot", extendsTestDependencies = true) + java { sourceCompatibility = JavaVersion.VERSION_24 targetCompatibility = JavaVersion.VERSION_24 diff --git a/BotCommands-jda-cache/src/testBot/kotlin/dev/freya02/botcommands/restart/jda/cache/Bot.kt b/BotCommands-jda-cache/src/testBot/kotlin/dev/freya02/botcommands/restart/jda/cache/Bot.kt new file mode 100644 index 000000000..9f447e96b --- /dev/null +++ b/BotCommands-jda-cache/src/testBot/kotlin/dev/freya02/botcommands/restart/jda/cache/Bot.kt @@ -0,0 +1,27 @@ +package dev.freya02.botcommands.restart.jda.cache + +import io.github.freya022.botcommands.api.core.JDAService +import io.github.freya022.botcommands.api.core.events.BReadyEvent +import io.github.freya022.botcommands.api.core.light +import io.github.freya022.botcommands.api.core.service.annotations.BService +import net.dv8tion.jda.api.hooks.IEventManager +import net.dv8tion.jda.api.requests.GatewayIntent +import net.dv8tion.jda.api.utils.cache.CacheFlag + +@BService +class Bot( + private val config: Config, +) : JDAService() { + + override val intents: Set = emptySet() + override val cacheFlags: Set = emptySet() + + override fun createJDA(event: BReadyEvent, eventManager: IEventManager) { + light( + token = config.token, + restConfig = null, + ) { + + } + } +} diff --git a/BotCommands-jda-cache/src/testBot/kotlin/dev/freya02/botcommands/restart/jda/cache/Config.kt b/BotCommands-jda-cache/src/testBot/kotlin/dev/freya02/botcommands/restart/jda/cache/Config.kt new file mode 100644 index 000000000..ae53eaa0b --- /dev/null +++ b/BotCommands-jda-cache/src/testBot/kotlin/dev/freya02/botcommands/restart/jda/cache/Config.kt @@ -0,0 +1,24 @@ +package dev.freya02.botcommands.restart.jda.cache + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.module.kotlin.readValue +import io.github.freya022.botcommands.api.core.service.annotations.BService +import io.github.freya022.botcommands.api.core.utils.DefaultObjectMapper +import kotlin.io.path.Path + +data class Config( + val token: String, +) { + + companion object { + val configDirectory = Path("test-files", "test", "dev-config") + private val configFile = configDirectory.resolve("config.json") + + @get:BService + val instance: Config by lazy { + DefaultObjectMapper.mapper + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .readValue(configFile.toFile()) + } + } +} diff --git a/BotCommands-jda-cache/src/testBot/kotlin/dev/freya02/botcommands/restart/jda/cache/Main.kt b/BotCommands-jda-cache/src/testBot/kotlin/dev/freya02/botcommands/restart/jda/cache/Main.kt new file mode 100644 index 000000000..53928096c --- /dev/null +++ b/BotCommands-jda-cache/src/testBot/kotlin/dev/freya02/botcommands/restart/jda/cache/Main.kt @@ -0,0 +1,19 @@ +package dev.freya02.botcommands.restart.jda.cache + +import ch.qos.logback.classic.ClassicConstants +import io.github.freya022.botcommands.api.core.BotCommands +import io.github.freya022.botcommands.api.restart.ExperimentalRestartApi +import kotlin.io.path.absolutePathString + +fun main() { + System.setProperty(ClassicConstants.CONFIG_FILE_PROPERTY, Config.configDirectory.resolve("logback-test.xml").absolutePathString()) + + BotCommands.create(emptyArray()) { + addSearchPath("dev.freya02.botcommands.restart.jda.cache") + + @OptIn(ExperimentalRestartApi::class) + restart { + cacheKey = "random text unique per instance" + } + } +} diff --git a/BotCommands-jda-cache/src/testBot/kotlin/dev/freya02/botcommands/restart/jda/cache/SlashTest.kt b/BotCommands-jda-cache/src/testBot/kotlin/dev/freya02/botcommands/restart/jda/cache/SlashTest.kt new file mode 100644 index 000000000..7467c4a8e --- /dev/null +++ b/BotCommands-jda-cache/src/testBot/kotlin/dev/freya02/botcommands/restart/jda/cache/SlashTest.kt @@ -0,0 +1,16 @@ +package dev.freya02.botcommands.restart.jda.cache + +import dev.minn.jda.ktx.messages.reply_ +import io.github.freya022.botcommands.api.commands.annotations.Command +import io.github.freya022.botcommands.api.commands.application.ApplicationCommand +import io.github.freya022.botcommands.api.commands.application.slash.GuildSlashEvent +import io.github.freya022.botcommands.api.commands.application.slash.annotations.JDASlashCommand + +@Command +class SlashTest : ApplicationCommand() { + + @JDASlashCommand(name = "test", description = "No description") + fun onSlashTest(event: GuildSlashEvent) { + event.reply_("foo", ephemeral = true).queue() + } +} From 430690de6488529adc4681583a2f91675055345c Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Mon, 30 Jun 2025 17:01:59 +0200 Subject: [PATCH 41/45] Notes --- .../freya022/botcommands/api/core/config/BRestartConfig.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BRestartConfig.kt b/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BRestartConfig.kt index 966e2c623..93cf47fad 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BRestartConfig.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BRestartConfig.kt @@ -8,6 +8,7 @@ import kotlin.time.Duration.Companion.seconds @InjectedService interface BRestartConfig { + // TODO document how to add agent (link wiki), fallback to dynamic loading val cacheKey: String? // TODO java duration @@ -26,4 +27,4 @@ class BRestartConfigBuilder : BRestartConfig { override val cacheKey = this@BRestartConfigBuilder.cacheKey override val restartDelay = this@BRestartConfigBuilder.restartDelay } -} \ No newline at end of file +} From 674a25caef079794e6a6006980df99546d6e9c4f Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Mon, 30 Jun 2025 23:50:16 +0200 Subject: [PATCH 42/45] Add ability to load agent at runtime Unfortunately build tool/IDE support sucks for java agents, while the JVM screams at runtime, it can be appeased --- BotCommands-jda-cache/build.gradle.kts | 5 +- .../botcommands/restart/jda/cache/Agent.kt | 101 ++++++++++++++++-- .../restart/jda/cache/JDAKeepAlive.kt | 10 ++ .../jda/cache/JDAKeepAliveLoadChecker.kt | 19 ++++ .../AlreadyLoadedClassesException.kt | 3 + .../exceptions/AttachSelfDeniedException.kt | 3 + .../IllegalAgentContainerException.kt | 3 + .../jda/cache/transformer/AgentTest.kt | 37 +++++++ .../botcommands/restart/jda/cache/Main.kt | 4 + 9 files changed, 174 insertions(+), 11 deletions(-) create mode 100644 BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/JDAKeepAlive.kt create mode 100644 BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/JDAKeepAliveLoadChecker.kt create mode 100644 BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/exceptions/AlreadyLoadedClassesException.kt create mode 100644 BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/exceptions/AttachSelfDeniedException.kt create mode 100644 BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/exceptions/IllegalAgentContainerException.kt create mode 100644 BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/AgentTest.kt diff --git a/BotCommands-jda-cache/build.gradle.kts b/BotCommands-jda-cache/build.gradle.kts index e6c08a9ce..00cc8bc3e 100644 --- a/BotCommands-jda-cache/build.gradle.kts +++ b/BotCommands-jda-cache/build.gradle.kts @@ -60,6 +60,7 @@ val jar by tasks.getting(Jar::class) { manifest { attributes( "Premain-Class" to "dev.freya02.botcommands.restart.jda.cache.Agent", + "Agent-Class" to "dev.freya02.botcommands.restart.jda.cache.Agent", ) } } @@ -67,5 +68,7 @@ val jar by tasks.getting(Jar::class) { tasks.withType { useJUnitPlatform() - jvmArgs("-javaagent:${jar.archiveFile.get().asFile.absolutePath}") + // Don't use "-javaagent" because [[AgentTest]] requires loading classes before the agent transforms them + jvmArgs("-Djdk.attach.allowAttachSelf=true") + jvmArgs("-Dbc.jda.cache.agentPath=${jar.archiveFile.get().asFile.absolutePath}") } diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/Agent.kt b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/Agent.kt index 9f46ef85b..e795de1f0 100644 --- a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/Agent.kt +++ b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/Agent.kt @@ -1,18 +1,99 @@ package dev.freya02.botcommands.restart.jda.cache -import dev.freya02.botcommands.restart.jda.cache.transformer.BContextImplTransformer -import dev.freya02.botcommands.restart.jda.cache.transformer.JDABuilderTransformer -import dev.freya02.botcommands.restart.jda.cache.transformer.JDAImplTransformer -import dev.freya02.botcommands.restart.jda.cache.transformer.JDAServiceTransformer +import com.sun.tools.attach.VirtualMachine +import dev.freya02.botcommands.restart.jda.cache.exceptions.AlreadyLoadedClassesException +import dev.freya02.botcommands.restart.jda.cache.exceptions.AttachSelfDeniedException +import dev.freya02.botcommands.restart.jda.cache.exceptions.IllegalAgentContainerException +import dev.freya02.botcommands.restart.jda.cache.transformer.* +import io.github.freya022.botcommands.api.core.utils.joinAsList import java.lang.instrument.Instrumentation +import java.lang.management.ManagementFactory +import java.nio.file.Path +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock +import kotlin.io.path.Path +import kotlin.io.path.absolutePathString +import kotlin.io.path.extension +import kotlin.io.path.toPath -object Agent { +internal object Agent { + + internal val transformers = mapOf( + CD_JDABuilder to JDABuilderTransformer, + CD_JDAImpl to JDAImplTransformer, + CD_JDAService to JDAServiceTransformer, + CD_BContextImpl to BContextImplTransformer, + ) + + private val lock = ReentrantLock() + internal var isLoaded = false + private set @JvmStatic fun premain(agentArgs: String?, inst: Instrumentation) { - inst.addTransformer(JDABuilderTransformer) - inst.addTransformer(JDAServiceTransformer) - inst.addTransformer(BContextImplTransformer) - inst.addTransformer(JDAImplTransformer) + lock.withLock { + isLoaded = true + } + + transformers.values.forEach(inst::addTransformer) + } + + @JvmStatic + fun agentmain(agentArgs: String?, inst: Instrumentation) { + lock.withLock { + if (isLoaded) return + isLoaded = true + } + + checkNoLoadedClassesAreToBeTransformed(inst.allLoadedClasses) + + transformers.values.forEach(inst::addTransformer) + } + + internal fun checkNoLoadedClassesAreToBeTransformed(allLoadedClasses: Array>) { + val transformedClasses = transformers.keys.mapTo(hashSetOf()) { it.packageName() + "." + it.displayName() } + val earlyLoadedClasses = allLoadedClasses.filter { it.name in transformedClasses } + if (earlyLoadedClasses.isNotEmpty()) { + // TODO wiki link (of the whole loading mechanism probably) + throw AlreadyLoadedClassesException( + "Dynamically loaded agents must be loaded before the classes it transforms, although it is recommended to add the agent via the command line, offending classes:\n" + + earlyLoadedClasses.joinAsList { it.name } + ) + } + } + + internal fun load() { + lock.withLock { + if (isLoaded) return + } + + // Check self-attaching agents are allowed + if (System.getProperty("jdk.attach.allowAttachSelf") != "true") { + // TODO wiki link (show how to do in IJ) + throw AttachSelfDeniedException("Can only dynamically load an agent with the '-Djdk.attach.allowAttachSelf=true' VM argument") + } + + // Get the agent JAR + // In a user's dev environment, this should be the dependency's JAR + // but in *our* test environment, we need a property to the JAR produced by Gradle, + // as it still uses the directory in the classpath + val agentSourcePath: Path = run { + // Property to test instrumentation is applied + System.getProperty("bc.jda.cache.agentPath")?.let { return@run Path(it) } + + javaClass.protectionDomain.codeSource.location.toURI().toPath() + } + if (agentSourcePath.extension != "jar") { + // TODO add wiki link about including the dependency only in dev envs + throw IllegalAgentContainerException( + "Can only dynamically load an agent using a JAR, this agent should only be loaded in development, please see the wiki\n" + + "Agent source @ ${agentSourcePath.absolutePathString()}" + ) + } + + // Load agent on ourselves + val jvm = VirtualMachine.attach(ManagementFactory.getRuntimeMXBean().pid.toString()) + jvm.loadAgent(agentSourcePath.absolutePathString()) + jvm.detach() } -} \ No newline at end of file +} diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/JDAKeepAlive.kt b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/JDAKeepAlive.kt new file mode 100644 index 000000000..624ad71a3 --- /dev/null +++ b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/JDAKeepAlive.kt @@ -0,0 +1,10 @@ +package dev.freya02.botcommands.restart.jda.cache + +// TODO move to api package +object JDAKeepAlive { + + @JvmStatic + fun install() { + Agent.load() + } +} diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/JDAKeepAliveLoadChecker.kt b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/JDAKeepAliveLoadChecker.kt new file mode 100644 index 000000000..c5c72598d --- /dev/null +++ b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/JDAKeepAliveLoadChecker.kt @@ -0,0 +1,19 @@ +package dev.freya02.botcommands.restart.jda.cache + +import io.github.freya022.botcommands.api.core.annotations.BEventListener +import io.github.freya022.botcommands.api.core.events.PreLoadEvent +import io.github.freya022.botcommands.api.core.service.annotations.BService +import io.github.oshai.kotlinlogging.KotlinLogging + +private val logger = KotlinLogging.logger { } + +@BService +internal class JDAKeepAliveLoadChecker { + + @BEventListener + fun onPreLoad(event: PreLoadEvent) { + if (!Agent.isLoaded) { + logger.info { "The JDA cache module is present but the agent has not been loaded, please check the instructions" } + } + } +} diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/exceptions/AlreadyLoadedClassesException.kt b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/exceptions/AlreadyLoadedClassesException.kt new file mode 100644 index 000000000..d9b0501f6 --- /dev/null +++ b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/exceptions/AlreadyLoadedClassesException.kt @@ -0,0 +1,3 @@ +package dev.freya02.botcommands.restart.jda.cache.exceptions + +internal class AlreadyLoadedClassesException(message: String) : IllegalStateException(message) diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/exceptions/AttachSelfDeniedException.kt b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/exceptions/AttachSelfDeniedException.kt new file mode 100644 index 000000000..5eed424fc --- /dev/null +++ b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/exceptions/AttachSelfDeniedException.kt @@ -0,0 +1,3 @@ +package dev.freya02.botcommands.restart.jda.cache.exceptions + +internal class AttachSelfDeniedException(message: String) : IllegalStateException(message) diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/exceptions/IllegalAgentContainerException.kt b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/exceptions/IllegalAgentContainerException.kt new file mode 100644 index 000000000..ba5e8c9ca --- /dev/null +++ b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/exceptions/IllegalAgentContainerException.kt @@ -0,0 +1,3 @@ +package dev.freya02.botcommands.restart.jda.cache.exceptions + +internal class IllegalAgentContainerException(message: String) : IllegalStateException(message) diff --git a/BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/AgentTest.kt b/BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/AgentTest.kt new file mode 100644 index 000000000..aa8c66df1 --- /dev/null +++ b/BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/AgentTest.kt @@ -0,0 +1,37 @@ +package dev.freya02.botcommands.restart.jda.cache.transformer + +import dev.freya02.botcommands.restart.jda.cache.Agent +import dev.freya02.botcommands.restart.jda.cache.exceptions.AlreadyLoadedClassesException +import io.mockk.every +import io.mockk.mockkObject +import org.junit.jupiter.api.assertNotNull +import kotlin.test.Test + +class AgentTest { + + @Test + fun `Dynamically loaded agent throws when classes are already loaded`() { + // Do a normal run to load classes + Agent.transformers.keys.forEach { + Class.forName("${it.packageName()}.${it.displayName()}") + } + + // Capture AlreadyLoadedClassesException + var agentmainException: AlreadyLoadedClassesException? = null + mockkObject(Agent) + every { Agent.checkNoLoadedClassesAreToBeTransformed(any()) } answers { + try { + callOriginal() + } catch (e: AlreadyLoadedClassesException) { + // This is thrown on a separate thread, so we need to capture it this way + agentmainException = e + // Don't throw + } + } + + // Load agent + Agent.load() + + assertNotNull(agentmainException) + } +} diff --git a/BotCommands-jda-cache/src/testBot/kotlin/dev/freya02/botcommands/restart/jda/cache/Main.kt b/BotCommands-jda-cache/src/testBot/kotlin/dev/freya02/botcommands/restart/jda/cache/Main.kt index 53928096c..ea6001c27 100644 --- a/BotCommands-jda-cache/src/testBot/kotlin/dev/freya02/botcommands/restart/jda/cache/Main.kt +++ b/BotCommands-jda-cache/src/testBot/kotlin/dev/freya02/botcommands/restart/jda/cache/Main.kt @@ -8,6 +8,10 @@ import kotlin.io.path.absolutePathString fun main() { System.setProperty(ClassicConstants.CONFIG_FILE_PROPERTY, Config.configDirectory.resolve("logback-test.xml").absolutePathString()) + // For those reading this, specifying the agent on the command line is better + // but build tool/IDE support sucks, so dynamic loading it is + JDAKeepAlive.install() + BotCommands.create(emptyArray()) { addSearchPath("dev.freya02.botcommands.restart.jda.cache") From fbad2cb9a789f27cacb97cbcf78288316ff95fd3 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Tue, 1 Jul 2025 00:08:58 +0200 Subject: [PATCH 43/45] Rename module to BotCommands-jda-keepalive --- .../botcommands/restart/jda/cache/JDAKeepAlive.kt | 10 ---------- .../build.gradle.kts | 6 +++--- .../botcommands/jda/keepalive/api/JDAKeepAlive.kt | 11 +++++++++++ .../botcommands/jda/keepalive/internal}/Agent.kt | 12 ++++++------ .../jda/keepalive/internal}/BufferingEventManager.kt | 4 ++-- .../jda/keepalive/internal}/DynamicCall.kt | 2 +- .../keepalive/internal}/JDABuilderConfiguration.kt | 4 ++-- .../jda/keepalive/internal}/JDABuilderSession.kt | 6 +++--- .../botcommands/jda/keepalive/internal}/JDACache.kt | 4 ++-- .../keepalive/internal}/JDAKeepAliveLoadChecker.kt | 4 ++-- .../exceptions/AlreadyLoadedClassesException.kt | 2 +- .../exceptions/AttachSelfDeniedException.kt | 2 +- .../exceptions/IllegalAgentContainerException.kt | 2 +- .../transformer/AbstractClassFileTransformer.kt | 4 ++-- .../internal}/transformer/BContextImplTransformer.kt | 4 ++-- .../internal}/transformer/ClassDescriptors.kt | 12 ++++++------ .../transformer/ContextualClassTransform.kt | 4 ++-- .../internal}/transformer/JDABuilderTransformer.kt | 8 ++++---- .../internal}/transformer/JDAImplTransformer.kt | 6 +++--- .../internal}/transformer/JDAServiceTransformer.kt | 6 +++--- .../internal}/transformer/utils/CodeBuilderUtils.kt | 4 ++-- .../internal}/transformer/utils/TransformUtils.kt | 4 ++-- .../jda/keepalive/internal}/utils/JvmUtils.kt | 4 ++-- .../freya02/botcommands/restart/jda/cache/Test.java | 0 .../freya02/botcommands/jda/keepalive}/AgentTest.kt | 6 +++--- .../transformer/BContextImplTransformerTest.kt | 4 ++-- .../transformer/JDABuilderTransformerTest.kt | 10 +++++----- .../keepalive}/transformer/JDAImplTransformerTest.kt | 6 +++--- .../transformer/JDAServiceTransformerTest.kt | 6 +++--- .../transformer/utils/CodeBuilderUtilsTest.kt | 8 +++++--- .../src/test/resources/logback-test.xml | 4 ++-- .../dev/freya02/botcommands/jda/keepalive}/Bot.kt | 2 +- .../dev/freya02/botcommands/jda/keepalive}/Config.kt | 2 +- .../dev/freya02/botcommands/jda/keepalive}/Main.kt | 5 +++-- .../freya02/botcommands/jda/keepalive}/SlashTest.kt | 2 +- .../META-INF/BotCommands-restarter.properties | 8 ++++---- settings.gradle.kts | 2 +- 37 files changed, 97 insertions(+), 93 deletions(-) delete mode 100644 BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/JDAKeepAlive.kt rename {BotCommands-jda-cache => BotCommands-jda-keepalive}/build.gradle.kts (88%) create mode 100644 BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/api/JDAKeepAlive.kt rename {BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache => BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal}/Agent.kt (87%) rename {BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache => BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal}/BufferingEventManager.kt (97%) rename {BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache => BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal}/DynamicCall.kt (85%) rename {BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache => BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal}/JDABuilderConfiguration.kt (98%) rename {BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache => BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal}/JDABuilderSession.kt (98%) rename {BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache => BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal}/JDACache.kt (90%) rename {BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache => BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal}/JDAKeepAliveLoadChecker.kt (72%) rename {BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache => BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal}/exceptions/AlreadyLoadedClassesException.kt (59%) rename {BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache => BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal}/exceptions/AttachSelfDeniedException.kt (58%) rename {BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache => BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal}/exceptions/IllegalAgentContainerException.kt (59%) rename {BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache => BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal}/transformer/AbstractClassFileTransformer.kt (91%) rename {BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache => BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal}/transformer/BContextImplTransformer.kt (96%) rename {BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache => BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal}/transformer/ClassDescriptors.kt (79%) rename {BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache => BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal}/transformer/ContextualClassTransform.kt (91%) rename {BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache => BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal}/transformer/JDABuilderTransformer.kt (98%) rename {BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache => BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal}/transformer/JDAImplTransformer.kt (98%) rename {BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache => BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal}/transformer/JDAServiceTransformer.kt (97%) rename {BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache => BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal}/transformer/utils/CodeBuilderUtils.kt (97%) rename {BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache => BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal}/transformer/utils/TransformUtils.kt (95%) rename {BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache => BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal}/utils/JvmUtils.kt (61%) rename {BotCommands-jda-cache => BotCommands-jda-keepalive}/src/test/java/dev/freya02/botcommands/restart/jda/cache/Test.java (100%) rename {BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer => BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive}/AgentTest.kt (82%) rename {BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache => BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive}/transformer/BContextImplTransformerTest.kt (85%) rename {BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache => BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive}/transformer/JDABuilderTransformerTest.kt (94%) rename {BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache => BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive}/transformer/JDAImplTransformerTest.kt (83%) rename {BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache => BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive}/transformer/JDAServiceTransformerTest.kt (96%) rename {BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache => BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive}/transformer/utils/CodeBuilderUtilsTest.kt (93%) rename {BotCommands-jda-cache => BotCommands-jda-keepalive}/src/test/resources/logback-test.xml (81%) rename {BotCommands-jda-cache/src/testBot/kotlin/dev/freya02/botcommands/restart/jda/cache => BotCommands-jda-keepalive/src/testBot/kotlin/dev/freya02/botcommands/jda/keepalive}/Bot.kt (93%) rename {BotCommands-jda-cache/src/testBot/kotlin/dev/freya02/botcommands/restart/jda/cache => BotCommands-jda-keepalive/src/testBot/kotlin/dev/freya02/botcommands/jda/keepalive}/Config.kt (93%) rename {BotCommands-jda-cache/src/testBot/kotlin/dev/freya02/botcommands/restart/jda/cache => BotCommands-jda-keepalive/src/testBot/kotlin/dev/freya02/botcommands/jda/keepalive}/Main.kt (80%) rename {BotCommands-jda-cache/src/testBot/kotlin/dev/freya02/botcommands/restart/jda/cache => BotCommands-jda-keepalive/src/testBot/kotlin/dev/freya02/botcommands/jda/keepalive}/SlashTest.kt (92%) diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/JDAKeepAlive.kt b/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/JDAKeepAlive.kt deleted file mode 100644 index 624ad71a3..000000000 --- a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/JDAKeepAlive.kt +++ /dev/null @@ -1,10 +0,0 @@ -package dev.freya02.botcommands.restart.jda.cache - -// TODO move to api package -object JDAKeepAlive { - - @JvmStatic - fun install() { - Agent.load() - } -} diff --git a/BotCommands-jda-cache/build.gradle.kts b/BotCommands-jda-keepalive/build.gradle.kts similarity index 88% rename from BotCommands-jda-cache/build.gradle.kts rename to BotCommands-jda-keepalive/build.gradle.kts index 00cc8bc3e..28eabf2ab 100644 --- a/BotCommands-jda-cache/build.gradle.kts +++ b/BotCommands-jda-keepalive/build.gradle.kts @@ -59,8 +59,8 @@ kotlin { val jar by tasks.getting(Jar::class) { manifest { attributes( - "Premain-Class" to "dev.freya02.botcommands.restart.jda.cache.Agent", - "Agent-Class" to "dev.freya02.botcommands.restart.jda.cache.Agent", + "Premain-Class" to "dev.freya02.botcommands.jda.keepalive.internal.Agent", + "Agent-Class" to "dev.freya02.botcommands.jda.keepalive.internal.Agent", ) } } @@ -70,5 +70,5 @@ tasks.withType { // Don't use "-javaagent" because [[AgentTest]] requires loading classes before the agent transforms them jvmArgs("-Djdk.attach.allowAttachSelf=true") - jvmArgs("-Dbc.jda.cache.agentPath=${jar.archiveFile.get().asFile.absolutePath}") + jvmArgs("-Dbc.jda.keepalive.agentPath=${jar.archiveFile.get().asFile.absolutePath}") } diff --git a/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/api/JDAKeepAlive.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/api/JDAKeepAlive.kt new file mode 100644 index 000000000..fd8564dc8 --- /dev/null +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/api/JDAKeepAlive.kt @@ -0,0 +1,11 @@ +package dev.freya02.botcommands.jda.keepalive.api + +import dev.freya02.botcommands.jda.keepalive.internal.Agent + +object JDAKeepAlive { + + @JvmStatic + fun install() { + Agent.load() + } +} diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/Agent.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/Agent.kt similarity index 87% rename from BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/Agent.kt rename to BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/Agent.kt index e795de1f0..e7375d659 100644 --- a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/Agent.kt +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/Agent.kt @@ -1,10 +1,10 @@ -package dev.freya02.botcommands.restart.jda.cache +package dev.freya02.botcommands.jda.keepalive.internal import com.sun.tools.attach.VirtualMachine -import dev.freya02.botcommands.restart.jda.cache.exceptions.AlreadyLoadedClassesException -import dev.freya02.botcommands.restart.jda.cache.exceptions.AttachSelfDeniedException -import dev.freya02.botcommands.restart.jda.cache.exceptions.IllegalAgentContainerException -import dev.freya02.botcommands.restart.jda.cache.transformer.* +import dev.freya02.botcommands.jda.keepalive.internal.exceptions.AlreadyLoadedClassesException +import dev.freya02.botcommands.jda.keepalive.internal.exceptions.AttachSelfDeniedException +import dev.freya02.botcommands.jda.keepalive.internal.exceptions.IllegalAgentContainerException +import dev.freya02.botcommands.jda.keepalive.internal.transformer.* import io.github.freya022.botcommands.api.core.utils.joinAsList import java.lang.instrument.Instrumentation import java.lang.management.ManagementFactory @@ -79,7 +79,7 @@ internal object Agent { // as it still uses the directory in the classpath val agentSourcePath: Path = run { // Property to test instrumentation is applied - System.getProperty("bc.jda.cache.agentPath")?.let { return@run Path(it) } + System.getProperty("bc.jda.keepalive.agentPath")?.let { return@run Path(it) } javaClass.protectionDomain.codeSource.location.toURI().toPath() } diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/BufferingEventManager.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/BufferingEventManager.kt similarity index 97% rename from BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/BufferingEventManager.kt rename to BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/BufferingEventManager.kt index 32c9581dd..98eb26703 100644 --- a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/BufferingEventManager.kt +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/BufferingEventManager.kt @@ -1,4 +1,4 @@ -package dev.freya02.botcommands.restart.jda.cache +package dev.freya02.botcommands.jda.keepalive.internal import net.dv8tion.jda.api.events.GenericEvent import net.dv8tion.jda.api.hooks.IEventManager @@ -64,4 +64,4 @@ internal class BufferingEventManager @DynamicCall constructor( return delegate.registeredListeners } } -} \ No newline at end of file +} diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/DynamicCall.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/DynamicCall.kt similarity index 85% rename from BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/DynamicCall.kt rename to BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/DynamicCall.kt index 8c393a4c7..89afd0a4f 100644 --- a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/DynamicCall.kt +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/DynamicCall.kt @@ -1,4 +1,4 @@ -package dev.freya02.botcommands.restart.jda.cache +package dev.freya02.botcommands.jda.keepalive.internal /** * This member is used by generated code and as such is not directly referenced. diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/JDABuilderConfiguration.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/JDABuilderConfiguration.kt similarity index 98% rename from BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/JDABuilderConfiguration.kt rename to BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/JDABuilderConfiguration.kt index 9910c7f40..4e8ca2348 100644 --- a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/JDABuilderConfiguration.kt +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/JDABuilderConfiguration.kt @@ -1,4 +1,4 @@ -package dev.freya02.botcommands.restart.jda.cache +package dev.freya02.botcommands.jda.keepalive.internal import io.github.freya022.botcommands.api.core.utils.enumSetOf import io.github.freya022.botcommands.api.core.utils.enumSetOfAll @@ -127,4 +127,4 @@ class JDABuilderConfiguration internal constructor() { ACTIVITY, ENABLE_SHUTDOWN_HOOK, } -} \ No newline at end of file +} diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/JDABuilderSession.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/JDABuilderSession.kt similarity index 98% rename from BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/JDABuilderSession.kt rename to BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/JDABuilderSession.kt index d4301120c..789ca3d7e 100644 --- a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/JDABuilderSession.kt +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/JDABuilderSession.kt @@ -1,6 +1,6 @@ -package dev.freya02.botcommands.restart.jda.cache +package dev.freya02.botcommands.jda.keepalive.internal -import dev.freya02.botcommands.restart.jda.cache.utils.isJvmShuttingDown +import dev.freya02.botcommands.jda.keepalive.internal.utils.isJvmShuttingDown import io.github.freya022.botcommands.api.core.BContext import io.github.oshai.kotlinlogging.KotlinLogging import net.dv8tion.jda.api.JDA @@ -172,4 +172,4 @@ internal class JDABuilderSession private constructor( } } } -} \ No newline at end of file +} diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/JDACache.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/JDACache.kt similarity index 90% rename from BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/JDACache.kt rename to BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/JDACache.kt index 7bc004aa0..845cfcfe2 100644 --- a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/JDACache.kt +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/JDACache.kt @@ -1,4 +1,4 @@ -package dev.freya02.botcommands.restart.jda.cache +package dev.freya02.botcommands.jda.keepalive.internal import net.dv8tion.jda.api.JDA @@ -18,4 +18,4 @@ internal object JDACache { val doShutdown: Runnable, val scheduleShutdownSignal: JDABuilderSession.ScheduleShutdownSignalWrapper, ) -} \ No newline at end of file +} diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/JDAKeepAliveLoadChecker.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/JDAKeepAliveLoadChecker.kt similarity index 72% rename from BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/JDAKeepAliveLoadChecker.kt rename to BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/JDAKeepAliveLoadChecker.kt index c5c72598d..f9d7bd2fa 100644 --- a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/JDAKeepAliveLoadChecker.kt +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/JDAKeepAliveLoadChecker.kt @@ -1,4 +1,4 @@ -package dev.freya02.botcommands.restart.jda.cache +package dev.freya02.botcommands.jda.keepalive.internal import io.github.freya022.botcommands.api.core.annotations.BEventListener import io.github.freya022.botcommands.api.core.events.PreLoadEvent @@ -13,7 +13,7 @@ internal class JDAKeepAliveLoadChecker { @BEventListener fun onPreLoad(event: PreLoadEvent) { if (!Agent.isLoaded) { - logger.info { "The JDA cache module is present but the agent has not been loaded, please check the instructions" } + logger.info { "The JDA keepalive module is present but the agent has not been loaded, please check the instructions" } } } } diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/exceptions/AlreadyLoadedClassesException.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/exceptions/AlreadyLoadedClassesException.kt similarity index 59% rename from BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/exceptions/AlreadyLoadedClassesException.kt rename to BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/exceptions/AlreadyLoadedClassesException.kt index d9b0501f6..7892821d7 100644 --- a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/exceptions/AlreadyLoadedClassesException.kt +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/exceptions/AlreadyLoadedClassesException.kt @@ -1,3 +1,3 @@ -package dev.freya02.botcommands.restart.jda.cache.exceptions +package dev.freya02.botcommands.jda.keepalive.internal.exceptions internal class AlreadyLoadedClassesException(message: String) : IllegalStateException(message) diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/exceptions/AttachSelfDeniedException.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/exceptions/AttachSelfDeniedException.kt similarity index 58% rename from BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/exceptions/AttachSelfDeniedException.kt rename to BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/exceptions/AttachSelfDeniedException.kt index 5eed424fc..afc05cc2d 100644 --- a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/exceptions/AttachSelfDeniedException.kt +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/exceptions/AttachSelfDeniedException.kt @@ -1,3 +1,3 @@ -package dev.freya02.botcommands.restart.jda.cache.exceptions +package dev.freya02.botcommands.jda.keepalive.internal.exceptions internal class AttachSelfDeniedException(message: String) : IllegalStateException(message) diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/exceptions/IllegalAgentContainerException.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/exceptions/IllegalAgentContainerException.kt similarity index 59% rename from BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/exceptions/IllegalAgentContainerException.kt rename to BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/exceptions/IllegalAgentContainerException.kt index ba5e8c9ca..5cf19abff 100644 --- a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/exceptions/IllegalAgentContainerException.kt +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/exceptions/IllegalAgentContainerException.kt @@ -1,3 +1,3 @@ -package dev.freya02.botcommands.restart.jda.cache.exceptions +package dev.freya02.botcommands.jda.keepalive.internal.exceptions internal class IllegalAgentContainerException(message: String) : IllegalStateException(message) diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/AbstractClassFileTransformer.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/AbstractClassFileTransformer.kt similarity index 91% rename from BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/AbstractClassFileTransformer.kt rename to BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/AbstractClassFileTransformer.kt index 39aa209f6..f4089b0a8 100644 --- a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/AbstractClassFileTransformer.kt +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/AbstractClassFileTransformer.kt @@ -1,4 +1,4 @@ -package dev.freya02.botcommands.restart.jda.cache.transformer +package dev.freya02.botcommands.jda.keepalive.internal.transformer import java.lang.instrument.ClassFileTransformer import java.security.ProtectionDomain @@ -24,4 +24,4 @@ internal abstract class AbstractClassFileTransformer protected constructor( } protected abstract fun transform(classData: ByteArray): ByteArray -} \ No newline at end of file +} diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/BContextImplTransformer.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/BContextImplTransformer.kt similarity index 96% rename from BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/BContextImplTransformer.kt rename to BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/BContextImplTransformer.kt index ea3c9c8f7..023db2932 100644 --- a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/BContextImplTransformer.kt +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/BContextImplTransformer.kt @@ -1,6 +1,6 @@ -package dev.freya02.botcommands.restart.jda.cache.transformer +package dev.freya02.botcommands.jda.keepalive.internal.transformer -import dev.freya02.botcommands.restart.jda.cache.transformer.utils.* +import dev.freya02.botcommands.jda.keepalive.internal.transformer.utils.* import io.github.oshai.kotlinlogging.KotlinLogging import java.lang.classfile.* import java.lang.constant.ConstantDescs.CD_String diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/ClassDescriptors.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/ClassDescriptors.kt similarity index 79% rename from BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/ClassDescriptors.kt rename to BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/ClassDescriptors.kt index 4ddf7b172..895a5369f 100644 --- a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/ClassDescriptors.kt +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/ClassDescriptors.kt @@ -1,9 +1,9 @@ -package dev.freya02.botcommands.restart.jda.cache.transformer +package dev.freya02.botcommands.jda.keepalive.internal.transformer -import dev.freya02.botcommands.restart.jda.cache.BufferingEventManager -import dev.freya02.botcommands.restart.jda.cache.JDABuilderConfiguration -import dev.freya02.botcommands.restart.jda.cache.JDABuilderSession -import dev.freya02.botcommands.restart.jda.cache.transformer.utils.classDesc +import dev.freya02.botcommands.jda.keepalive.internal.BufferingEventManager +import dev.freya02.botcommands.jda.keepalive.internal.JDABuilderConfiguration +import dev.freya02.botcommands.jda.keepalive.internal.JDABuilderSession +import dev.freya02.botcommands.jda.keepalive.internal.transformer.utils.classDesc import org.intellij.lang.annotations.Language import java.lang.constant.ClassDesc @@ -29,4 +29,4 @@ internal val CD_JDABuilderConfiguration = classDesc() private fun classDescOf(@Language("java", prefix = "import ", suffix = ";") name: String): ClassDesc { return ClassDesc.of(name) -} \ No newline at end of file +} diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/ContextualClassTransform.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/ContextualClassTransform.kt similarity index 91% rename from BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/ContextualClassTransform.kt rename to BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/ContextualClassTransform.kt index e857339ea..925ddb414 100644 --- a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/ContextualClassTransform.kt +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/ContextualClassTransform.kt @@ -1,4 +1,4 @@ -package dev.freya02.botcommands.restart.jda.cache.transformer +package dev.freya02.botcommands.jda.keepalive.internal.transformer import java.lang.classfile.ClassBuilder import java.lang.classfile.ClassElement @@ -22,4 +22,4 @@ internal interface ContextualClassTransform : ClassTransform { context(classBuilder: ClassBuilder) fun acceptContextual(classElement: ClassElement) { } -} \ No newline at end of file +} diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDABuilderTransformer.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/JDABuilderTransformer.kt similarity index 98% rename from BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDABuilderTransformer.kt rename to BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/JDABuilderTransformer.kt index 02322160b..818b454cf 100644 --- a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDABuilderTransformer.kt +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/JDABuilderTransformer.kt @@ -1,7 +1,7 @@ -package dev.freya02.botcommands.restart.jda.cache.transformer +package dev.freya02.botcommands.jda.keepalive.internal.transformer -import dev.freya02.botcommands.restart.jda.cache.JDABuilderConfiguration -import dev.freya02.botcommands.restart.jda.cache.transformer.utils.* +import dev.freya02.botcommands.jda.keepalive.internal.JDABuilderConfiguration +import dev.freya02.botcommands.jda.keepalive.internal.transformer.utils.* import io.github.oshai.kotlinlogging.KotlinLogging import java.lang.classfile.* import java.lang.classfile.ClassFile.* @@ -255,4 +255,4 @@ private class CaptureSetterParametersTransform : ContextualClassTransform { methodModel.methodTypeSymbol().parameterList(), ) } -} \ No newline at end of file +} diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDAImplTransformer.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/JDAImplTransformer.kt similarity index 98% rename from BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDAImplTransformer.kt rename to BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/JDAImplTransformer.kt index b14423534..5a4abc6ee 100644 --- a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDAImplTransformer.kt +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/JDAImplTransformer.kt @@ -1,6 +1,6 @@ -package dev.freya02.botcommands.restart.jda.cache.transformer +package dev.freya02.botcommands.jda.keepalive.internal.transformer -import dev.freya02.botcommands.restart.jda.cache.transformer.utils.* +import dev.freya02.botcommands.jda.keepalive.internal.transformer.utils.* import io.github.oshai.kotlinlogging.KotlinLogging import java.lang.classfile.* import java.lang.classfile.ClassFile.* @@ -239,4 +239,4 @@ private class AwaitShutdownTransform : ContextualClassTransform { } } } -} \ No newline at end of file +} diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDAServiceTransformer.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/JDAServiceTransformer.kt similarity index 97% rename from BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDAServiceTransformer.kt rename to BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/JDAServiceTransformer.kt index 04e032bab..202256c4a 100644 --- a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDAServiceTransformer.kt +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/JDAServiceTransformer.kt @@ -1,6 +1,6 @@ -package dev.freya02.botcommands.restart.jda.cache.transformer +package dev.freya02.botcommands.jda.keepalive.internal.transformer -import dev.freya02.botcommands.restart.jda.cache.transformer.utils.* +import dev.freya02.botcommands.jda.keepalive.internal.transformer.utils.* import io.github.oshai.kotlinlogging.KotlinLogging import java.lang.classfile.* import java.lang.constant.ConstantDescs.CD_String @@ -111,4 +111,4 @@ private class WrapOnReadyEventWithJDABuilderSessionTransform(private val classMo const val NEW_NAME = $$"lambda$onReadyEvent$BotCommands$withBuilderSession" } -} \ No newline at end of file +} diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/utils/CodeBuilderUtils.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/utils/CodeBuilderUtils.kt similarity index 97% rename from BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/utils/CodeBuilderUtils.kt rename to BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/utils/CodeBuilderUtils.kt index 04e4f22f9..3ca5e881a 100644 --- a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/utils/CodeBuilderUtils.kt +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/utils/CodeBuilderUtils.kt @@ -1,4 +1,4 @@ -package dev.freya02.botcommands.restart.jda.cache.transformer.utils +package dev.freya02.botcommands.jda.keepalive.internal.transformer.utils import java.lang.classfile.ClassFileBuilder import java.lang.classfile.ClassFileElement @@ -84,4 +84,4 @@ internal fun createLambda( // This is usually the same as "interfaceMethodType" MethodTypeDesc.of(methodReturnType, methodArguments), ) -} \ No newline at end of file +} diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/utils/TransformUtils.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/utils/TransformUtils.kt similarity index 95% rename from BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/utils/TransformUtils.kt rename to BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/utils/TransformUtils.kt index 4f59e6f47..b94859d27 100644 --- a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/utils/TransformUtils.kt +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/utils/TransformUtils.kt @@ -1,4 +1,4 @@ -package dev.freya02.botcommands.restart.jda.cache.transformer.utils +package dev.freya02.botcommands.jda.keepalive.internal.transformer.utils import java.lang.classfile.ClassBuilder import java.lang.classfile.ClassFile.ACC_SYNTHETIC @@ -39,4 +39,4 @@ internal fun MethodModel.transferCodeTo(targetMethodName: String, visibility: Ac internal fun MethodModel.toFullyQualifiedString(): String { val className = parent().getOrNull()?.thisClass()?.asSymbol()?.displayName() ?: "" return "$className#${methodName().stringValue()}${methodTypeSymbol().displayDescriptor()}" -} \ No newline at end of file +} diff --git a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/utils/JvmUtils.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/utils/JvmUtils.kt similarity index 61% rename from BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/utils/JvmUtils.kt rename to BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/utils/JvmUtils.kt index bb941886e..622d5e3d0 100644 --- a/BotCommands-jda-cache/src/main/kotlin/dev/freya02/botcommands/restart/jda/cache/utils/JvmUtils.kt +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/utils/JvmUtils.kt @@ -1,4 +1,4 @@ -package dev.freya02.botcommands.restart.jda.cache.utils +package dev.freya02.botcommands.jda.keepalive.internal.utils internal fun isJvmShuttingDown() = try { Runtime.getRuntime().removeShutdownHook(NullShutdownHook) @@ -7,4 +7,4 @@ internal fun isJvmShuttingDown() = try { true } -private object NullShutdownHook : Thread() \ No newline at end of file +private object NullShutdownHook : Thread() diff --git a/BotCommands-jda-cache/src/test/java/dev/freya02/botcommands/restart/jda/cache/Test.java b/BotCommands-jda-keepalive/src/test/java/dev/freya02/botcommands/restart/jda/cache/Test.java similarity index 100% rename from BotCommands-jda-cache/src/test/java/dev/freya02/botcommands/restart/jda/cache/Test.java rename to BotCommands-jda-keepalive/src/test/java/dev/freya02/botcommands/restart/jda/cache/Test.java diff --git a/BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/AgentTest.kt b/BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive/AgentTest.kt similarity index 82% rename from BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/AgentTest.kt rename to BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive/AgentTest.kt index aa8c66df1..aec9d1b61 100644 --- a/BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/AgentTest.kt +++ b/BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive/AgentTest.kt @@ -1,7 +1,7 @@ -package dev.freya02.botcommands.restart.jda.cache.transformer +package dev.freya02.botcommands.jda.keepalive -import dev.freya02.botcommands.restart.jda.cache.Agent -import dev.freya02.botcommands.restart.jda.cache.exceptions.AlreadyLoadedClassesException +import dev.freya02.botcommands.jda.keepalive.internal.Agent +import dev.freya02.botcommands.jda.keepalive.internal.exceptions.AlreadyLoadedClassesException import io.mockk.every import io.mockk.mockkObject import org.junit.jupiter.api.assertNotNull diff --git a/BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/BContextImplTransformerTest.kt b/BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive/transformer/BContextImplTransformerTest.kt similarity index 85% rename from BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/BContextImplTransformerTest.kt rename to BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive/transformer/BContextImplTransformerTest.kt index a03ae2da7..ebb65e8f8 100644 --- a/BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/BContextImplTransformerTest.kt +++ b/BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive/transformer/BContextImplTransformerTest.kt @@ -1,4 +1,4 @@ -package dev.freya02.botcommands.restart.jda.cache.transformer +package dev.freya02.botcommands.jda.keepalive.transformer import org.junit.jupiter.api.assertDoesNotThrow import kotlin.test.Test @@ -12,4 +12,4 @@ class BContextImplTransformerTest { .getDeclaredMethod("doScheduleShutdownSignal", Function0::class.java) } } -} \ No newline at end of file +} diff --git a/BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDABuilderTransformerTest.kt b/BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive/transformer/JDABuilderTransformerTest.kt similarity index 94% rename from BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDABuilderTransformerTest.kt rename to BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive/transformer/JDABuilderTransformerTest.kt index f7989ff85..46b52b22a 100644 --- a/BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDABuilderTransformerTest.kt +++ b/BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive/transformer/JDABuilderTransformerTest.kt @@ -1,8 +1,8 @@ -package dev.freya02.botcommands.restart.jda.cache.transformer +package dev.freya02.botcommands.jda.keepalive.transformer -import dev.freya02.botcommands.restart.jda.cache.BufferingEventManager -import dev.freya02.botcommands.restart.jda.cache.JDABuilderConfiguration -import dev.freya02.botcommands.restart.jda.cache.JDABuilderSession +import dev.freya02.botcommands.jda.keepalive.internal.BufferingEventManager +import dev.freya02.botcommands.jda.keepalive.internal.JDABuilderConfiguration +import dev.freya02.botcommands.jda.keepalive.internal.JDABuilderSession import io.mockk.* import net.dv8tion.jda.api.JDA import net.dv8tion.jda.api.JDABuilder @@ -132,4 +132,4 @@ class JDABuilderTransformerTest { } private class ExpectedException : RuntimeException() -} \ No newline at end of file +} diff --git a/BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDAImplTransformerTest.kt b/BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive/transformer/JDAImplTransformerTest.kt similarity index 83% rename from BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDAImplTransformerTest.kt rename to BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive/transformer/JDAImplTransformerTest.kt index da37b3f9f..bd8bf4116 100644 --- a/BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDAImplTransformerTest.kt +++ b/BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive/transformer/JDAImplTransformerTest.kt @@ -1,6 +1,6 @@ -package dev.freya02.botcommands.restart.jda.cache.transformer +package dev.freya02.botcommands.jda.keepalive.transformer -import dev.freya02.botcommands.restart.jda.cache.JDABuilderSession +import dev.freya02.botcommands.jda.keepalive.internal.JDABuilderSession import io.mockk.* import net.dv8tion.jda.internal.JDAImpl import kotlin.test.Test @@ -23,4 +23,4 @@ class JDAImplTransformerTest { verify(exactly = 1) { builderSession.onShutdown(jda, any()) } } -} \ No newline at end of file +} diff --git a/BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDAServiceTransformerTest.kt b/BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive/transformer/JDAServiceTransformerTest.kt similarity index 96% rename from BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDAServiceTransformerTest.kt rename to BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive/transformer/JDAServiceTransformerTest.kt index 9c7badcc5..2b04768db 100644 --- a/BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/JDAServiceTransformerTest.kt +++ b/BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive/transformer/JDAServiceTransformerTest.kt @@ -1,6 +1,6 @@ -package dev.freya02.botcommands.restart.jda.cache.transformer +package dev.freya02.botcommands.jda.keepalive.transformer -import dev.freya02.botcommands.restart.jda.cache.JDABuilderSession +import dev.freya02.botcommands.jda.keepalive.internal.JDABuilderSession import io.github.freya022.botcommands.api.core.JDAService import io.github.freya022.botcommands.api.core.events.BReadyEvent import io.mockk.* @@ -84,4 +84,4 @@ class JDAServiceTransformerTest { verify(exactly = 0) { JDABuilderSession.withBuilderSession(any(), any()) } } -} \ No newline at end of file +} diff --git a/BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/utils/CodeBuilderUtilsTest.kt b/BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive/transformer/utils/CodeBuilderUtilsTest.kt similarity index 93% rename from BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/utils/CodeBuilderUtilsTest.kt rename to BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive/transformer/utils/CodeBuilderUtilsTest.kt index 5931e19ef..24170a839 100644 --- a/BotCommands-jda-cache/src/test/kotlin/dev/freya02/botcommands/restart/jda/cache/transformer/utils/CodeBuilderUtilsTest.kt +++ b/BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive/transformer/utils/CodeBuilderUtilsTest.kt @@ -1,6 +1,8 @@ -package dev.freya02.botcommands.restart.jda.cache.transformer.utils +package dev.freya02.botcommands.jda.keepalive.transformer.utils -import dev.freya02.botcommands.restart.jda.cache.transformer.* +import dev.freya02.botcommands.jda.keepalive.internal.transformer.* +import dev.freya02.botcommands.jda.keepalive.internal.transformer.utils.createLambda +import dev.freya02.botcommands.jda.keepalive.internal.transformer.utils.lambdaMetafactoryDesc import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource @@ -118,4 +120,4 @@ object CodeBuilderUtilsTest { ) ), ) -} \ No newline at end of file +} diff --git a/BotCommands-jda-cache/src/test/resources/logback-test.xml b/BotCommands-jda-keepalive/src/test/resources/logback-test.xml similarity index 81% rename from BotCommands-jda-cache/src/test/resources/logback-test.xml rename to BotCommands-jda-keepalive/src/test/resources/logback-test.xml index 4a6e03caa..97d41eb06 100644 --- a/BotCommands-jda-cache/src/test/resources/logback-test.xml +++ b/BotCommands-jda-keepalive/src/test/resources/logback-test.xml @@ -6,9 +6,9 @@ - + - \ No newline at end of file + diff --git a/BotCommands-jda-cache/src/testBot/kotlin/dev/freya02/botcommands/restart/jda/cache/Bot.kt b/BotCommands-jda-keepalive/src/testBot/kotlin/dev/freya02/botcommands/jda/keepalive/Bot.kt similarity index 93% rename from BotCommands-jda-cache/src/testBot/kotlin/dev/freya02/botcommands/restart/jda/cache/Bot.kt rename to BotCommands-jda-keepalive/src/testBot/kotlin/dev/freya02/botcommands/jda/keepalive/Bot.kt index 9f447e96b..bbbc42084 100644 --- a/BotCommands-jda-cache/src/testBot/kotlin/dev/freya02/botcommands/restart/jda/cache/Bot.kt +++ b/BotCommands-jda-keepalive/src/testBot/kotlin/dev/freya02/botcommands/jda/keepalive/Bot.kt @@ -1,4 +1,4 @@ -package dev.freya02.botcommands.restart.jda.cache +package dev.freya02.botcommands.jda.keepalive import io.github.freya022.botcommands.api.core.JDAService import io.github.freya022.botcommands.api.core.events.BReadyEvent diff --git a/BotCommands-jda-cache/src/testBot/kotlin/dev/freya02/botcommands/restart/jda/cache/Config.kt b/BotCommands-jda-keepalive/src/testBot/kotlin/dev/freya02/botcommands/jda/keepalive/Config.kt similarity index 93% rename from BotCommands-jda-cache/src/testBot/kotlin/dev/freya02/botcommands/restart/jda/cache/Config.kt rename to BotCommands-jda-keepalive/src/testBot/kotlin/dev/freya02/botcommands/jda/keepalive/Config.kt index ae53eaa0b..6b80bd3b3 100644 --- a/BotCommands-jda-cache/src/testBot/kotlin/dev/freya02/botcommands/restart/jda/cache/Config.kt +++ b/BotCommands-jda-keepalive/src/testBot/kotlin/dev/freya02/botcommands/jda/keepalive/Config.kt @@ -1,4 +1,4 @@ -package dev.freya02.botcommands.restart.jda.cache +package dev.freya02.botcommands.jda.keepalive import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.module.kotlin.readValue diff --git a/BotCommands-jda-cache/src/testBot/kotlin/dev/freya02/botcommands/restart/jda/cache/Main.kt b/BotCommands-jda-keepalive/src/testBot/kotlin/dev/freya02/botcommands/jda/keepalive/Main.kt similarity index 80% rename from BotCommands-jda-cache/src/testBot/kotlin/dev/freya02/botcommands/restart/jda/cache/Main.kt rename to BotCommands-jda-keepalive/src/testBot/kotlin/dev/freya02/botcommands/jda/keepalive/Main.kt index ea6001c27..9989c84e2 100644 --- a/BotCommands-jda-cache/src/testBot/kotlin/dev/freya02/botcommands/restart/jda/cache/Main.kt +++ b/BotCommands-jda-keepalive/src/testBot/kotlin/dev/freya02/botcommands/jda/keepalive/Main.kt @@ -1,6 +1,7 @@ -package dev.freya02.botcommands.restart.jda.cache +package dev.freya02.botcommands.jda.keepalive import ch.qos.logback.classic.ClassicConstants +import dev.freya02.botcommands.jda.keepalive.api.JDAKeepAlive import io.github.freya022.botcommands.api.core.BotCommands import io.github.freya022.botcommands.api.restart.ExperimentalRestartApi import kotlin.io.path.absolutePathString @@ -13,7 +14,7 @@ fun main() { JDAKeepAlive.install() BotCommands.create(emptyArray()) { - addSearchPath("dev.freya02.botcommands.restart.jda.cache") + addSearchPath("dev.freya02.botcommands.jda.keepalive") @OptIn(ExperimentalRestartApi::class) restart { diff --git a/BotCommands-jda-cache/src/testBot/kotlin/dev/freya02/botcommands/restart/jda/cache/SlashTest.kt b/BotCommands-jda-keepalive/src/testBot/kotlin/dev/freya02/botcommands/jda/keepalive/SlashTest.kt similarity index 92% rename from BotCommands-jda-cache/src/testBot/kotlin/dev/freya02/botcommands/restart/jda/cache/SlashTest.kt rename to BotCommands-jda-keepalive/src/testBot/kotlin/dev/freya02/botcommands/jda/keepalive/SlashTest.kt index 7467c4a8e..de835aba6 100644 --- a/BotCommands-jda-cache/src/testBot/kotlin/dev/freya02/botcommands/restart/jda/cache/SlashTest.kt +++ b/BotCommands-jda-keepalive/src/testBot/kotlin/dev/freya02/botcommands/jda/keepalive/SlashTest.kt @@ -1,4 +1,4 @@ -package dev.freya02.botcommands.restart.jda.cache +package dev.freya02.botcommands.jda.keepalive import dev.minn.jda.ktx.messages.reply_ import io.github.freya022.botcommands.api.commands.annotations.Command diff --git a/BotCommands-restarter/src/main/resources/META-INF/BotCommands-restarter.properties b/BotCommands-restarter/src/main/resources/META-INF/BotCommands-restarter.properties index 4f3cfc990..bc0eb5969 100644 --- a/BotCommands-restarter/src/main/resources/META-INF/BotCommands-restarter.properties +++ b/BotCommands-restarter/src/main/resources/META-INF/BotCommands-restarter.properties @@ -2,12 +2,12 @@ # It is in the production resources so it can be applied regardless of in which module it is used # This principally excludes source sets which are used by the restarter, to avoid classpath issues -restart.exclude.jda-cache-prod=BotCommands-jda-cache/build/classes/(?:kotlin|java)/main -restart.exclude.jda-cache-prod-res=BotCommands-jda-cache/build/resources/main +restart.exclude.jda-keepalive-prod=BotCommands-jda-keepalive/build/classes/(?:kotlin|java)/main +restart.exclude.jda-keepalive-prod-res=BotCommands-jda-keepalive/build/resources/main restart.exclude.restarter-prod=BotCommands-restarter/build/classes/(?:kotlin|java)/main restart.exclude.restarter-prod-res=BotCommands-restarter/build/resources/main # Have to use a negative lookbehind as the main module doesn't have a dedicated directory -restart.exclude.core-prod=(? Date: Tue, 1 Jul 2025 00:10:39 +0200 Subject: [PATCH 44/45] Remove Java test class --- .../botcommands/restart/jda/cache/Test.java | 34 ------------------- 1 file changed, 34 deletions(-) delete mode 100644 BotCommands-jda-keepalive/src/test/java/dev/freya02/botcommands/restart/jda/cache/Test.java diff --git a/BotCommands-jda-keepalive/src/test/java/dev/freya02/botcommands/restart/jda/cache/Test.java b/BotCommands-jda-keepalive/src/test/java/dev/freya02/botcommands/restart/jda/cache/Test.java deleted file mode 100644 index ccef89d6c..000000000 --- a/BotCommands-jda-keepalive/src/test/java/dev/freya02/botcommands/restart/jda/cache/Test.java +++ /dev/null @@ -1,34 +0,0 @@ -package dev.freya02.botcommands.restart.jda.cache; - -import io.github.freya022.botcommands.api.core.JDAService; -import io.github.freya022.botcommands.api.core.events.BReadyEvent; -import net.dv8tion.jda.api.hooks.IEventManager; -import net.dv8tion.jda.api.requests.GatewayIntent; -import net.dv8tion.jda.api.utils.cache.CacheFlag; -import org.jetbrains.annotations.NotNull; - -import java.util.Set; - -public class Test extends JDAService { - - @NotNull - @Override - public Set getIntents() { - return Set.of(); - } - - @NotNull - @Override - public Set getCacheFlags() { - return Set.of(); - } - - @Override - protected void createJDA(@NotNull BReadyEvent bReadyEvent, @NotNull IEventManager iEventManager) { - System.out.println("Test"); - } - - void something(BReadyEvent bReadyEvent, IEventManager iEventManager) { - throw new IllegalStateException("test"); - } -} From 949c41d04966c6519b663a3ff623fa83b1106dde Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Tue, 1 Jul 2025 22:46:24 +0200 Subject: [PATCH 45/45] Load classes and executables using the current thread's `contextClassLoader` --- .../internal/utils/ReflectionMetadata.kt | 81 ++++++++++++++----- 1 file changed, 59 insertions(+), 22 deletions(-) diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/utils/ReflectionMetadata.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/utils/ReflectionMetadata.kt index 4f2210a2d..48520b4d0 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/utils/ReflectionMetadata.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/utils/ReflectionMetadata.kt @@ -79,7 +79,7 @@ private fun ReflectionMetadata.getMethodMetadataOrNull(function: KFunction<*>): private class ReflectionMetadataScanner private constructor( private val config: BConfig, - private val bootstrap: BotCommandsBootstrap + private val bootstrap: BotCommandsBootstrap, ) { private val classGraphProcessors: List = @@ -201,7 +201,7 @@ private class ReflectionMetadataScanner private constructor( private fun List.processClasses(): List { return onEach { classInfo -> try { - val kClass = tryGetClass(classInfo) ?: return@onEach + val kClass = tryGetClass(classInfo)?.kotlin ?: return@onEach processMethods(classInfo, kClass) @@ -215,18 +215,16 @@ private class ReflectionMetadataScanner private constructor( } } - private fun tryGetClass(classInfo: ClassInfo): KClass<*>? { + private fun tryGetClass(classInfo: ClassInfo): Class<*>? { // Ignore unknown classes return try { - classInfo.loadClass().kotlin - } catch(e: IllegalArgumentException) { - // ClassGraph wraps Class#forName exceptions in an IAE - val cause = e.cause - if (cause is ClassNotFoundException || cause is NoClassDefFoundError) { + loadClass(classInfo.name) + } catch (e: Throwable) { + if (e is ClassNotFoundException || e is NoClassDefFoundError) { return if (logger.isTraceEnabled()) { logger.traceNull(e) { "Ignoring ${classInfo.name} due to unsatisfied dependency" } } else { - logger.debugNull { "Ignoring ${classInfo.name} due to unsatisfied dependency: ${cause.message}" } + logger.debugNull { "Ignoring ${classInfo.name} due to unsatisfied dependency: ${e.message}" } } } else { throw e @@ -234,10 +232,7 @@ private class ReflectionMetadataScanner private constructor( } } - private fun processMethods( - classInfo: ClassInfo, - kClass: KClass, - ) { + private fun processMethods(classInfo: ClassInfo, kClass: KClass<*>) { for (methodInfo in classInfo.declaredMethodAndConstructorInfo) { //Don't inspect methods with generics if (methodInfo.parameterInfo @@ -245,7 +240,7 @@ private class ReflectionMetadataScanner private constructor( .any { it is TypeVariableSignature || (it is ArrayTypeSignature && it.elementTypeSignature is TypeVariableSignature) } ) continue - val method: Executable = tryGetExecutable(methodInfo) ?: continue + val method: Executable = tryGetExecutable(kClass, methodInfo) ?: continue val nullabilities = getMethodParameterNullabilities(methodInfo, method) methodMetadataMap[method] = MethodMetadata(methodInfo.minLineNum, nullabilities) @@ -255,17 +250,15 @@ private class ReflectionMetadataScanner private constructor( } } - private fun tryGetExecutable(methodInfo: MethodInfo): Executable? { + private fun tryGetExecutable(kClass: KClass<*>, methodInfo: MethodInfo): Executable? { // Ignore methods with missing dependencies (such as parameters from unknown dependencies) try { return when { - methodInfo.isConstructor -> methodInfo.loadClassAndGetConstructor() - else -> methodInfo.loadClassAndGetMethod() + methodInfo.isConstructor -> kClass.java.getConstructor(*methodInfo.getParameterTypes()) + else -> kClass.java.getMethod(methodInfo.name, *methodInfo.getParameterTypes()) } - } catch(e: IllegalArgumentException) { - // ClassGraph wraps exceptions in an IAE - val cause = e.cause - if (cause is ClassNotFoundException || cause is NoClassDefFoundError) { + } catch(e: Throwable) { + if (e is ClassNotFoundException || e is NoClassDefFoundError) { return if (logger.isTraceEnabled()) { logger.traceNull(e) { "Ignoring method due to unsatisfied dependencies in ${methodInfo.shortSignature}" } } else { @@ -277,6 +270,50 @@ private class ReflectionMetadataScanner private constructor( } } + private fun MethodInfo.getParameterTypes(): Array> = parameterInfo.mapToArray { + it.typeSignatureOrTypeDescriptor.loadClass() + } + + private fun TypeSignature.loadClass(): Class<*> { + val parameterType = this.toActualParameterType() + return when (parameterType) { + is ClassRefTypeSignature -> loadClass(parameterType.className) + is BaseTypeSignature -> parameterType.type + is ArrayTypeSignature -> { + val elementType = parameterType.elementTypeSignature.loadClass() + // Create an array of the target number of dimensions, with size zero in each dimension + val array = java.lang.reflect.Array.newInstance(elementType, *IntArray(parameterType.numDimensions)) + array.javaClass + } + else -> error("Unhandled parameter type: $parameterType") + } + } + + private val ClassRefTypeSignature.className: String + get() = classInfo?.name ?: fullyQualifiedClassName + + private fun TypeSignature.toActualParameterType(): TypeSignature { + if (this is TypeVariableSignature) { +// val typeParameter = resolve() +// if (typeParameter.classBound != null) { +// return typeParameter.classBound +// } +// +// if (typeParameter.interfaceBounds != null && typeParameter.interfaceBounds!!.isNotEmpty()) { +// return typeParameter.interfaceBounds!![0] +// } +// +// error("TypeVariableSignature has no bounds") + throwInternal("Methods with generics should have been ignored") + } + + return this + } + + private fun loadClass(name: String): Class<*> { + return Class.forName(name, false, Thread.currentThread().contextClassLoader) + } + private fun getMethodParameterNullabilities(methodInfo: MethodInfo, method: Executable): List { val nullabilities = methodInfo.parameterInfo.dropLast(if (method.isSuspend) 1 else 0).map { parameterInfo -> parameterInfo.annotationInfo.any { it.name.endsWith("Nullable") } @@ -330,4 +367,4 @@ internal val KFunction<*>.lineNumber: Int get() = ReflectionMetadata.instance.getMethodMetadata(this).line internal val KFunction<*>.lineNumberOrNull: Int? - get() = ReflectionMetadata.instance.getMethodMetadataOrNull(this)?.line \ No newline at end of file + get() = ReflectionMetadata.instance.getMethodMetadataOrNull(this)?.line