From 6a54e56237db7e1add222115187567a21b08dd25 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Sat, 7 Feb 2026 16:34:34 +0100 Subject: [PATCH 01/11] Added an internal application start event So the restarter can hook into it --- .../github/freya022/botcommands/api/core/BotCommands.kt | 7 +++++++ .../internal/core/events/BApplicationStartEvent.kt | 5 +++++ .../internal/core/hooks/ApplicationStartListener.kt | 8 ++++++++ 3 files changed, 20 insertions(+) create mode 100644 BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/events/BApplicationStartEvent.kt create mode 100644 BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/ApplicationStartListener.kt diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/core/BotCommands.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/core/BotCommands.kt index 96955e6c4..dc1fa5ece 100644 --- a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/core/BotCommands.kt +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/api/core/BotCommands.kt @@ -7,8 +7,11 @@ 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.events.BApplicationStartEvent +import io.github.freya022.botcommands.internal.core.hooks.ApplicationStartListener import io.github.freya022.botcommands.internal.core.service.BCBotCommandsBootstrap import io.github.oshai.kotlinlogging.KotlinLogging +import java.util.* import kotlin.time.DurationUnit import kotlin.time.measureTimedValue @@ -60,6 +63,10 @@ object BotCommands { } private fun build(config: BConfig): BContext { + val startEvent = BApplicationStartEvent(config) + val startListeners = ServiceLoader.load(ApplicationStartListener::class.java) + startListeners.forEach { it.onApplicationStart(startEvent) } + val (context, duration) = measureTimedValue { val bootstrap = BCBotCommandsBootstrap(config) bootstrap.injectAndLoadServices() diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/events/BApplicationStartEvent.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/events/BApplicationStartEvent.kt new file mode 100644 index 000000000..14da5c780 --- /dev/null +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/events/BApplicationStartEvent.kt @@ -0,0 +1,5 @@ +package io.github.freya022.botcommands.internal.core.events + +import io.github.freya022.botcommands.api.core.config.BConfig + +class BApplicationStartEvent internal constructor(val config: BConfig) diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/ApplicationStartListener.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/ApplicationStartListener.kt new file mode 100644 index 000000000..d8ef3cdcf --- /dev/null +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/ApplicationStartListener.kt @@ -0,0 +1,8 @@ +package io.github.freya022.botcommands.internal.core.hooks + +import io.github.freya022.botcommands.internal.core.events.BApplicationStartEvent + +interface ApplicationStartListener { + + fun onApplicationStart(event: BApplicationStartEvent) +} From fb533161f3c4f6ac9aa858f0268a42a250dc2d49 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Sat, 7 Feb 2026 16:39:00 +0100 Subject: [PATCH 02/11] Add restarter module --- BotCommands-restarter/build.gradle.kts | 31 +++ .../restarter/api/ExperimentalRestartApi.kt | 17 ++ .../restarter/api/config/BRestartConfig.kt | 114 +++++++++ .../internal/ImmediateRestartException.kt | 29 +++ .../restarter/internal/LeakSafeExecutor.kt | 61 +++++ .../restarter/internal/RestartClassLoader.kt | 40 +++ .../RestartClassLoaderFull.kt.disabled | 116 +++++++++ .../restarter/internal/RestartListener.kt | 5 + .../restarter/internal/Restarter.kt | 128 ++++++++++ .../internal/annotations/RequiresRestarter.kt | 9 + .../internal/exceptions/InternalException.kt | 30 +++ .../RestarterApplicationStartListener.kt | 21 ++ .../internal/services/RestarterService.kt | 27 ++ .../internal/sources/SourceDirectories.kt | 53 ++++ .../sources/SourceDirectoriesListener.kt | 7 + .../internal/sources/SourceDirectory.kt | 90 +++++++ .../sources/SourceDirectoryListener.kt | 5 + .../restarter/internal/sources/SourceFile.kt | 15 ++ .../restarter/internal/sources/SourceFiles.kt | 16 ++ .../restarter/internal/utils/AppClasspath.kt | 60 +++++ .../restarter/internal/utils/NIO.kt | 66 +++++ .../internal/watcher/ClasspathListener.kt | 42 ++++ .../internal/watcher/ClasspathWatcher.kt | 230 ++++++++++++++++++ .../src/main/resources/META-INF/bc.packages | 2 + ...ternal.core.hooks.ApplicationStartListener | 1 + build.gradle.kts | 1 + settings.gradle.kts | 1 + 27 files changed, 1217 insertions(+) create mode 100644 BotCommands-restarter/build.gradle.kts create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/api/ExperimentalRestartApi.kt create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/api/config/BRestartConfig.kt create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/ImmediateRestartException.kt create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/LeakSafeExecutor.kt create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/RestartClassLoader.kt create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/RestartClassLoaderFull.kt.disabled create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/RestartListener.kt create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/Restarter.kt create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/annotations/RequiresRestarter.kt create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/exceptions/InternalException.kt create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/services/RestarterApplicationStartListener.kt create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/services/RestarterService.kt create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceDirectories.kt create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceDirectoriesListener.kt create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceDirectory.kt create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceDirectoryListener.kt create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceFile.kt create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceFiles.kt create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/utils/AppClasspath.kt create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/utils/NIO.kt create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/watcher/ClasspathListener.kt create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/watcher/ClasspathWatcher.kt create mode 100644 BotCommands-restarter/src/main/resources/META-INF/bc.packages create mode 100644 BotCommands-restarter/src/main/resources/META-INF/services/io.github.freya022.botcommands.internal.core.hooks.ApplicationStartListener diff --git a/BotCommands-restarter/build.gradle.kts b/BotCommands-restarter/build.gradle.kts new file mode 100644 index 000000000..d16853bdf --- /dev/null +++ b/BotCommands-restarter/build.gradle.kts @@ -0,0 +1,31 @@ +import dev.freya02.botcommands.plugins.configureJarArtifact + +plugins { + id("repositories-conventions") + id("kotlin-conventions") + id("publish-conventions") + id("dokka-conventions") +} + +dependencies { + api(projects.botCommandsCore) + + // Logging + implementation(libs.kotlin.logging) +} + +kotlin { + compilerOptions { + freeCompilerArgs.addAll( + "-opt-in=dev.freya02.botcommands.restarter.api.ExperimentalRestartApi", + ) + } +} + +publishedProjectEnvironment { + configureJarArtifact( + artifactId = "BotCommands-restarter", + description = "Enables restarting your bot on the same JVM during development.", + url = "https://github.com/freya022/BotCommands/tree/3.X/BotCommands-restarter", + ) +} diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/api/ExperimentalRestartApi.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/api/ExperimentalRestartApi.kt new file mode 100644 index 000000000..fc0b81d7a --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/api/ExperimentalRestartApi.kt @@ -0,0 +1,17 @@ +package dev.freya02.botcommands.restarter.api + +/** + * Opt-in marker annotation for the hot restart 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 (@ExperimentalRestartApi) for more details.", + level = RequiresOptIn.Level.ERROR +) +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY, AnnotationTarget.PROPERTY_SETTER) +@Retention(AnnotationRetention.BINARY) +@MustBeDocumented +annotation class ExperimentalRestartApi diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/api/config/BRestartConfig.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/api/config/BRestartConfig.kt new file mode 100644 index 000000000..322ad4760 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/api/config/BRestartConfig.kt @@ -0,0 +1,114 @@ +package dev.freya02.botcommands.restarter.api.config + +import dev.freya02.botcommands.restarter.api.ExperimentalRestartApi +import dev.freya02.botcommands.restarter.internal.exceptions.throwInternal +import io.github.freya022.botcommands.api.core.config.BConfig +import io.github.freya022.botcommands.api.core.config.BConfigBuilder +import io.github.freya022.botcommands.api.core.config.IConfig +import io.github.freya022.botcommands.api.core.config.getConfigOrNull +import io.github.freya022.botcommands.api.core.service.annotations.InjectedService +import io.github.freya022.botcommands.internal.core.config.ConfigDSL +import java.time.Duration as JavaDuration +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.toJavaDuration +import kotlin.time.toKotlinDuration + +@ExperimentalRestartApi +interface RestarterConfigProps { + + /** + * The program arguments passed to the main function upon restarting. + */ + val startArgs: List + + /** + * The time to wait before assuming all changes were compiled, + * so the application can be restarted with the new changes. + * + * Default: 1 second + */ + val restartDelay: Duration + + /** + * Returns the time to wait before assuming all changes were compiled, + * so the application can be restarted with the new changes. + * + * Default: 1 second + */ + fun getRestartDelay(): JavaDuration = restartDelay.toJavaDuration() +} + +/** + * @see [RestarterConfigBuilder] + */ +@InjectedService +@ExperimentalRestartApi +interface RestarterConfig : IConfig, RestarterConfigProps { + override val configType get() = RestarterConfig::class.java +} + +@ExperimentalRestartApi +internal val BConfig.restarterConfig: RestarterConfig + get() = getConfigOrNull() + ?: throwInternal("Attempted to fetch a configuration of a disabled feature") + +/** + * Builder of [RestarterConfig]. + * + * @see [RestarterConfigBuilder.Companion.create] + */ +@ConfigDSL +@ExperimentalRestartApi +class RestarterConfigBuilder private constructor( + override val startArgs: List, +) : RestarterConfigProps { + + override var restartDelay: Duration = 1.seconds + + /** + * Sets the time to wait before assuming all changes were compiled, + * so the application can be restarted with the new changes. + * + * Default: 1 second + */ + fun setRestartDelay(delay: JavaDuration): RestarterConfigBuilder { + this.restartDelay = delay.toKotlinDuration() + return this + } + + /** + * Builds the [RestarterConfig], you can register the built configuration with [BConfigBuilder.withConfig]. + */ + fun build(): RestarterConfig = object : RestarterConfig { + override val startArgs = this@RestarterConfigBuilder.startArgs + override val restartDelay = this@RestarterConfigBuilder.restartDelay + } + + companion object { + + /** + * Creates a new [RestarterConfigBuilder], you must [build] it and [register][BConfigBuilder.withConfig] it. + * + * @param args The program arguments, they will be passed to the main method upon restarting + */ + @JvmStatic + fun create(args: Array): RestarterConfigBuilder { + return RestarterConfigBuilder(args.toList()) + } + } +} + +/** + * Registers the restarter module. + * + * @param args The program arguments, they will be passed to the main method upon restarting + * @param block A block for further configuration + */ +@ExperimentalRestartApi +fun BConfigBuilder.withRestarter(args: Array, block: RestarterConfigBuilder.() -> Unit = { }) { + val config = RestarterConfigBuilder.create(args) + .apply(block) + .build() + withConfig(config) +} diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/ImmediateRestartException.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/ImmediateRestartException.kt new file mode 100644 index 000000000..18b73c4a4 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/ImmediateRestartException.kt @@ -0,0 +1,29 @@ +package dev.freya02.botcommands.restarter.internal + +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() + } + } + } +} diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/LeakSafeExecutor.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/LeakSafeExecutor.kt new file mode 100644 index 000000000..4f623fab7 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/LeakSafeExecutor.kt @@ -0,0 +1,61 @@ +package dev.freya02.botcommands.restarter.internal + +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 [dev.freya02.botcommands.restarter.internal.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) + } + } + } +} diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/RestartClassLoader.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/RestartClassLoader.kt new file mode 100644 index 000000000..145eacb03 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/RestartClassLoader.kt @@ -0,0 +1,40 @@ +package dev.freya02.botcommands.restarter.internal + +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) + } +} diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/RestartClassLoaderFull.kt.disabled b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/RestartClassLoaderFull.kt.disabled new file mode 100644 index 000000000..34784a02e --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/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() + } + } + } +} diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/RestartListener.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/RestartListener.kt new file mode 100644 index 000000000..c39389edc --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/RestartListener.kt @@ -0,0 +1,5 @@ +package dev.freya02.botcommands.restarter.internal + +internal interface RestartListener { + fun beforeStop() +} diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/Restarter.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/Restarter.kt new file mode 100644 index 000000000..f276b71b9 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/Restarter.kt @@ -0,0 +1,128 @@ +package dev.freya02.botcommands.restarter.internal + +import dev.freya02.botcommands.restarter.internal.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.paths.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 + } + + internal fun addListener(listener: RestartListener) { + listeners += listener + } + + private fun initialize(): Nothing { + val throwable = leakSafeExecutor.callAndWait { start() } + if (throwable != null) + throw throwable + ImmediateRestartException.throwAndHandle() + } + + /** + * Runs each [dev.freya02.botcommands.restarter.internal.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/restarter/internal/annotations/RequiresRestarter.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/annotations/RequiresRestarter.kt new file mode 100644 index 000000000..5506d5ec2 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/annotations/RequiresRestarter.kt @@ -0,0 +1,9 @@ +package dev.freya02.botcommands.restarter.internal.annotations + +import dev.freya02.botcommands.restarter.api.config.RestarterConfig +import io.github.freya022.botcommands.api.core.service.annotations.Dependencies +import io.github.freya022.botcommands.api.core.service.annotations.RequiresDefaultInjection + +@Dependencies(RestarterConfig::class) +@RequiresDefaultInjection +internal annotation class RequiresRestarter diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/exceptions/InternalException.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/exceptions/InternalException.kt new file mode 100644 index 000000000..28bfda48b --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/exceptions/InternalException.kt @@ -0,0 +1,30 @@ +package dev.freya02.botcommands.restarter.internal.exceptions + +import io.github.freya022.botcommands.api.BCInfo +import io.github.freya022.botcommands.api.core.utils.getSignature +import net.dv8tion.jda.api.JDAInfo +import kotlin.reflect.KFunction + +internal class InternalException internal constructor( + message: String, + throwable: Throwable? = null, +) : RuntimeException( + internalErrorMessage( + message + ), throwable) + +internal fun throwInternal(message: String): Nothing = + throw InternalException(message) + +internal fun throwInternal(function: KFunction<*>, message: String): Nothing = + throw InternalException( + "$message\n Function: ${ + function.getSignature( + returnType = true + ) + }" + ) + +internal fun getDiagnosticVersions() = "[ BC version: ${BCInfo.VERSION} | Current JDA version: ${JDAInfo.VERSION} ]" + +internal fun internalErrorMessage(message: String) = "$message, please report this to the devs. ${getDiagnosticVersions()}" diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/services/RestarterApplicationStartListener.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/services/RestarterApplicationStartListener.kt new file mode 100644 index 000000000..b43361a3b --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/services/RestarterApplicationStartListener.kt @@ -0,0 +1,21 @@ +package dev.freya02.botcommands.restarter.internal.services + +import dev.freya02.botcommands.restarter.api.config.RestarterConfig +import dev.freya02.botcommands.restarter.internal.Restarter +import dev.freya02.botcommands.restarter.internal.annotations.RequiresRestarter +import io.github.freya022.botcommands.api.core.config.getConfigOrNull +import io.github.freya022.botcommands.api.core.service.annotations.BService +import io.github.freya022.botcommands.internal.core.events.BApplicationStartEvent +import io.github.freya022.botcommands.internal.core.hooks.ApplicationStartListener + +@BService +@RequiresRestarter +internal class RestarterApplicationStartListener : ApplicationStartListener { + + override fun onApplicationStart(event: BApplicationStartEvent) { + val restarterConfig = event.config.getConfigOrNull() + if (restarterConfig != null) { + Restarter.initialize(restarterConfig.startArgs) + } + } +} diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/services/RestarterService.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/services/RestarterService.kt new file mode 100644 index 000000000..7214b0a78 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/services/RestarterService.kt @@ -0,0 +1,27 @@ +package dev.freya02.botcommands.restarter.internal.services + +import dev.freya02.botcommands.restarter.api.config.restarterConfig +import dev.freya02.botcommands.restarter.internal.RestartListener +import dev.freya02.botcommands.restarter.internal.Restarter +import dev.freya02.botcommands.restarter.internal.annotations.RequiresRestarter +import dev.freya02.botcommands.restarter.internal.watcher.ClasspathWatcher +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 + +@BService +@RequiresRestarter +internal class RestarterService { + + @BEventListener + fun onPostLoad(event: PostLoadEvent) { + val context = event.context + val restartConfig = context.config.restarterConfig + Restarter.instance.addListener(object : RestartListener { + override fun beforeStop() { + context.shutdownNow() + } + }) + ClasspathWatcher.initialize(restartConfig.restartDelay) + } +} diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceDirectories.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceDirectories.kt new file mode 100644 index 000000000..33da6563d --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceDirectories.kt @@ -0,0 +1,53 @@ +package dev.freya02.botcommands.restarter.internal.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 +} diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceDirectoriesListener.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceDirectoriesListener.kt new file mode 100644 index 000000000..1d6ac8950 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceDirectoriesListener.kt @@ -0,0 +1,7 @@ +package dev.freya02.botcommands.restarter.internal.sources + +internal interface SourceDirectoriesListener { + fun onChange(command: () -> Unit) + + fun onCancel() +} diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceDirectory.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceDirectory.kt new file mode 100644 index 000000000..0b32f8906 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceDirectory.kt @@ -0,0 +1,90 @@ +package dev.freya02.botcommands.restarter.internal.sources + +import dev.freya02.botcommands.restarter.internal.utils.walkDirectories +import dev.freya02.botcommands.restarter.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, +) { + + 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/restarter/internal/sources/SourceDirectoryListener.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceDirectoryListener.kt new file mode 100644 index 000000000..62cbe9b22 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceDirectoryListener.kt @@ -0,0 +1,5 @@ +package dev.freya02.botcommands.restarter.internal.sources + +internal fun interface SourceDirectoryListener { + fun onChange(sourcesFilesFactory: () -> SourceFiles) +} diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceFile.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceFile.kt new file mode 100644 index 000000000..1814457f8 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceFile.kt @@ -0,0 +1,15 @@ +package dev.freya02.botcommands.restarter.internal.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 diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceFiles.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceFiles.kt new file mode 100644 index 000000000..614575cd6 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceFiles.kt @@ -0,0 +1,16 @@ +package dev.freya02.botcommands.restarter.internal.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) diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/utils/AppClasspath.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/utils/AppClasspath.kt new file mode 100644 index 000000000..b4ad3f41d --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/utils/AppClasspath.kt @@ -0,0 +1,60 @@ +package dev.freya02.botcommands.restarter.internal.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 { + + 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]" + for ((key, value) in properties) { + if (key !is String) continue + if (value !is String) continue + + 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() } + .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 + } +} diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/utils/NIO.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/utils/NIO.kt new file mode 100644 index 000000000..ba8d8c63d --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/utils/NIO.kt @@ -0,0 +1,66 @@ +package dev.freya02.botcommands.restarter.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 +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 + }) +} diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/watcher/ClasspathListener.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/watcher/ClasspathListener.kt new file mode 100644 index 000000000..2aa0c64bc --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/watcher/ClasspathListener.kt @@ -0,0 +1,42 @@ +package dev.freya02.botcommands.restarter.internal.watcher + +import dev.freya02.botcommands.restarter.internal.Restarter +import dev.freya02.botcommands.restarter.internal.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() + } +} diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/watcher/ClasspathWatcher.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/watcher/ClasspathWatcher.kt new file mode 100644 index 000000000..c79b94922 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/watcher/ClasspathWatcher.kt @@ -0,0 +1,230 @@ +package dev.freya02.botcommands.restarter.internal.watcher + +import dev.freya02.botcommands.restarter.internal.Restarter +import dev.freya02.botcommands.restarter.internal.sources.DeletedSourceFile +import dev.freya02.botcommands.restarter.internal.sources.SourceFile +import dev.freya02.botcommands.restarter.internal.sources.SourceFiles +import dev.freya02.botcommands.restarter.internal.sources.plus +import dev.freya02.botcommands.restarter.internal.utils.AppClasspath +import dev.freya02.botcommands.restarter.internal.utils.walkDirectories +import dev.freya02.botcommands.restarter.internal.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( + settings: Settings, +) { + + private val settingsHolder = SettingsHolder(settings) + + 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.paths.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 + } + + // 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) + } + } + } + + /** + * 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() { + // 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" } + + // 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) } + + val exception = Restarter.instance.restart() + if (exception != null) throw exception + } catch (e: Exception) { + logger.error(e) { "Restart failed, waiting for the next build" } + settingsHolder.set(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 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, + ) + + 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.settingsHolder.set(settings) + } + } + } + } +} + +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/resources/META-INF/bc.packages b/BotCommands-restarter/src/main/resources/META-INF/bc.packages new file mode 100644 index 000000000..976feb7da --- /dev/null +++ b/BotCommands-restarter/src/main/resources/META-INF/bc.packages @@ -0,0 +1,2 @@ +dev.freya02.botcommands.restarter.api +dev.freya02.botcommands.restarter.internal diff --git a/BotCommands-restarter/src/main/resources/META-INF/services/io.github.freya022.botcommands.internal.core.hooks.ApplicationStartListener b/BotCommands-restarter/src/main/resources/META-INF/services/io.github.freya022.botcommands.internal.core.hooks.ApplicationStartListener new file mode 100644 index 000000000..d16b9cfc9 --- /dev/null +++ b/BotCommands-restarter/src/main/resources/META-INF/services/io.github.freya022.botcommands.internal.core.hooks.ApplicationStartListener @@ -0,0 +1 @@ +dev.freya02.botcommands.restarter.internal.services.RestarterApplicationStartListener diff --git a/build.gradle.kts b/build.gradle.kts index 264bd9d26..6865c5ea3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -38,6 +38,7 @@ dependencies { dokka(projects.botCommandsTypesafeMessages.core) dokka(projects.botCommandsTypesafeMessages.bc) dokka(projects.botCommandsTypesafeMessages.spring) + dokka(projects.botCommandsRestarter) } tasks.withType { diff --git a/settings.gradle.kts b/settings.gradle.kts index eb22e9298..79312c75c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -19,4 +19,5 @@ include( ":BotCommands-typesafe-messages:bc", ":BotCommands-typesafe-messages:spring", ) +include(":BotCommands-restarter") include(":test-bot") From 880b840e5fc09da23376f93abde08118783b50b9 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Tue, 10 Feb 2026 20:09:16 +0100 Subject: [PATCH 03/11] Add implementation of RestartClassLoaderAdapterFactory So other code-generation modules can avoid issues --- .../BCRestartClassLoaderAdapterFactory.kt | 21 +++++++++++++++++++ .../restarter/internal/RestartClassLoader.kt | 4 ++++ ...restarter.RestartClassLoaderAdapterFactory | 1 + 3 files changed, 26 insertions(+) create mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/BCRestartClassLoaderAdapterFactory.kt create mode 100644 BotCommands-restarter/src/main/resources/META-INF/services/io.github.freya022.botcommands.internal.core.restarter.RestartClassLoaderAdapterFactory diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/BCRestartClassLoaderAdapterFactory.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/BCRestartClassLoaderAdapterFactory.kt new file mode 100644 index 000000000..70f2c9c14 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/BCRestartClassLoaderAdapterFactory.kt @@ -0,0 +1,21 @@ +package dev.freya02.botcommands.restarter.internal + +import io.github.freya022.botcommands.internal.core.restarter.RestartClassLoaderAdapter +import io.github.freya022.botcommands.internal.core.restarter.RestartClassLoaderAdapterFactory + +internal class BCRestartClassLoaderAdapterFactory : RestartClassLoaderAdapterFactory { + + override fun wrapOrNull(loader: ClassLoader): RestartClassLoaderAdapter? { + return if (loader is RestartClassLoader) { + BCRestartClassLoaderAdapter(loader) + } else { + null + } + } + + private class BCRestartClassLoaderAdapter(private val loader: RestartClassLoader) : RestartClassLoaderAdapter { + + override fun publicDefineClass(name: String, bytes: ByteArray): Class<*> = + loader.publicDefineClass(name, bytes) + } +} diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/RestartClassLoader.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/RestartClassLoader.kt index 145eacb03..7addfde88 100644 --- a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/RestartClassLoader.kt +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/RestartClassLoader.kt @@ -37,4 +37,8 @@ internal class RestartClassLoader internal constructor( override fun findClass(name: String): Class<*> { return super.findClass(name) } + + internal fun publicDefineClass(name: String, bytes: ByteArray): Class<*> { + return defineClass(name, bytes, 0, bytes.size) + } } diff --git a/BotCommands-restarter/src/main/resources/META-INF/services/io.github.freya022.botcommands.internal.core.restarter.RestartClassLoaderAdapterFactory b/BotCommands-restarter/src/main/resources/META-INF/services/io.github.freya022.botcommands.internal.core.restarter.RestartClassLoaderAdapterFactory new file mode 100644 index 000000000..e1e163cbe --- /dev/null +++ b/BotCommands-restarter/src/main/resources/META-INF/services/io.github.freya022.botcommands.internal.core.restarter.RestartClassLoaderAdapterFactory @@ -0,0 +1 @@ +dev.freya02.botcommands.restarter.internal.BCRestartClassLoaderAdapterFactory From 5bd2e63c1ed3edb92e3778e061264ed27b1ee285 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Sat, 7 Feb 2026 16:39:22 +0100 Subject: [PATCH 04/11] test-bot: Use restarter module --- test-bot/build.gradle.kts | 2 + .../dev/freya02/botcommands/bot/Main.kt | 83 +++++++++---------- .../META-INF/BotCommands-restarter.properties | 22 +++++ 3 files changed, 64 insertions(+), 43 deletions(-) create mode 100644 test-bot/src/test/resources/META-INF/BotCommands-restarter.properties diff --git a/test-bot/build.gradle.kts b/test-bot/build.gradle.kts index 7cd31750c..1e4c8c288 100644 --- a/test-bot/build.gradle.kts +++ b/test-bot/build.gradle.kts @@ -33,6 +33,8 @@ dependencies { testRuntimeOnly(projects.botCommandsTypesafeMessages.bc) testRuntimeOnly(projects.botCommandsTypesafeMessages.spring) + implementation(projects.botCommandsRestarter) + // ---------------------------- SPRING TEST BOT DEPENDENCIES --------------------------- // Spring module diff --git a/test-bot/src/test/kotlin/dev/freya02/botcommands/bot/Main.kt b/test-bot/src/test/kotlin/dev/freya02/botcommands/bot/Main.kt index d4cc96563..01c9e0c9a 100644 --- a/test-bot/src/test/kotlin/dev/freya02/botcommands/bot/Main.kt +++ b/test-bot/src/test/kotlin/dev/freya02/botcommands/bot/Main.kt @@ -4,76 +4,73 @@ import ch.qos.logback.classic.ClassicConstants import dev.freya02.botcommands.bot.config.Environment import dev.freya02.botcommands.method.accessors.api.MethodAccessorsConfig import dev.freya02.botcommands.method.accessors.api.annotations.ExperimentalMethodAccessorsApi +import dev.freya02.botcommands.restarter.api.ExperimentalRestartApi +import dev.freya02.botcommands.restarter.api.config.withRestarter import io.github.freya022.botcommands.api.core.BotCommands import io.github.freya022.botcommands.api.core.config.DevConfig import io.github.oshai.kotlinlogging.KotlinLogging import net.dv8tion.jda.api.interactions.DiscordLocale import kotlin.io.path.absolutePathString -import kotlin.system.exitProcess import kotlin.time.Duration.Companion.milliseconds -const val botName = "BC Test" - object Main { private val logger by lazy { KotlinLogging.logger { } } @JvmStatic fun main(args: Array) { - try { - System.setProperty(ClassicConstants.CONFIG_FILE_PROPERTY, Environment.logbackConfigPath.absolutePathString()) - logger.info { "Loading logback configuration at ${Environment.logbackConfigPath.absolutePathString()}" } + System.setProperty(ClassicConstants.CONFIG_FILE_PROPERTY, Environment.logbackConfigPath.absolutePathString()) + logger.info { "Loading logback configuration at ${Environment.logbackConfigPath.absolutePathString()}" } - @OptIn(ExperimentalMethodAccessorsApi::class) - MethodAccessorsConfig.preferClassFileAccessors() + @OptIn(ExperimentalMethodAccessorsApi::class) + MethodAccessorsConfig.preferClassFileAccessors() - BotCommands.create { - disableExceptionsInDMs = true + BotCommands.create { + disableExceptionsInDMs = true - addSearchPath("dev.freya02.botcommands.bot") + addSearchPath("dev.freya02.botcommands.bot") - database { - queryLogThreshold = 250.milliseconds + database { + queryLogThreshold = 250.milliseconds - @OptIn(DevConfig::class) - dumpLongTransactions = true - } - - 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) + } + + modals { + enable = true } - } catch (e: Exception) { - logger.error(e) { "Could not start the test bot" } - exitProcess(1) + + @OptIn(ExperimentalRestartApi::class) + withRestarter(args) } } } diff --git a/test-bot/src/test/resources/META-INF/BotCommands-restarter.properties b/test-bot/src/test/resources/META-INF/BotCommands-restarter.properties new file mode 100644 index 000000000..e757b5f63 --- /dev/null +++ b/test-bot/src/test/resources/META-INF/BotCommands-restarter.properties @@ -0,0 +1,22 @@ +# Remove the directories of other modules from the restart classpath + +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 + +restart.exclude.core-prod=BotCommands-core/build/classes/(?:kotlin|java)/main +restart.exclude.core-prod-res=BotCommands-core/build/resources/main + +restart.exclude.spring-prod=BotCommands-spring/build/classes/(?:kotlin|java)/main +restart.exclude.spring-prod-res=BotCommands-spring/build/resources/main + +restart.exclude.jda-ktx-prod=BotCommands-jda-ktx/build/classes/(?:kotlin|java)/main +restart.exclude.jda-ktx-prod-res=BotCommands-jda-ktx/build/resources/main + +restart.exclude.typesafe-messages-prod=BotCommands-typesafe-messages/[\\w-]+/build/classes/(?:kotlin|java)/main +restart.exclude.typesafe-messages-prod-res=BotCommands-typesafe-messages/[\\w-]+/build/resources/main + +restart.exclude.method-accessors-prod=BotCommands-method-accessors/[\\w-]+/build/classes/(?:kotlin|java)/main +restart.exclude.method-accessors-prod-res=BotCommands-method-accessors/[\\w-]+/build/resources/main From 0882e7d5cf6d613947926f200c66a84fdf8b245e Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:06:54 +0100 Subject: [PATCH 05/11] Remove unused code --- .../RestartClassLoaderFull.kt.disabled | 116 ------------------ .../internal/sources/SourceDirectories.kt | 53 -------- .../sources/SourceDirectoriesListener.kt | 7 -- .../internal/sources/SourceDirectory.kt | 90 -------------- .../sources/SourceDirectoryListener.kt | 5 - .../restarter/internal/sources/SourceFile.kt | 6 +- .../internal/watcher/ClasspathListener.kt | 42 ------- .../internal/watcher/ClasspathWatcher.kt | 1 - 8 files changed, 1 insertion(+), 319 deletions(-) delete mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/RestartClassLoaderFull.kt.disabled delete mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceDirectories.kt delete mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceDirectoriesListener.kt delete mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceDirectory.kt delete mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceDirectoryListener.kt delete mode 100644 BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/watcher/ClasspathListener.kt diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/RestartClassLoaderFull.kt.disabled b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/RestartClassLoaderFull.kt.disabled deleted file mode 100644 index 34784a02e..000000000 --- a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/RestartClassLoaderFull.kt.disabled +++ /dev/null @@ -1,116 +0,0 @@ -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() - } - } - } -} diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceDirectories.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceDirectories.kt deleted file mode 100644 index 33da6563d..000000000 --- a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceDirectories.kt +++ /dev/null @@ -1,53 +0,0 @@ -package dev.freya02.botcommands.restarter.internal.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 -} diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceDirectoriesListener.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceDirectoriesListener.kt deleted file mode 100644 index 1d6ac8950..000000000 --- a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceDirectoriesListener.kt +++ /dev/null @@ -1,7 +0,0 @@ -package dev.freya02.botcommands.restarter.internal.sources - -internal interface SourceDirectoriesListener { - fun onChange(command: () -> Unit) - - fun onCancel() -} diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceDirectory.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceDirectory.kt deleted file mode 100644 index 0b32f8906..000000000 --- a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceDirectory.kt +++ /dev/null @@ -1,90 +0,0 @@ -package dev.freya02.botcommands.restarter.internal.sources - -import dev.freya02.botcommands.restarter.internal.utils.walkDirectories -import dev.freya02.botcommands.restarter.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, -) { - - 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/restarter/internal/sources/SourceDirectoryListener.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceDirectoryListener.kt deleted file mode 100644 index 62cbe9b22..000000000 --- a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceDirectoryListener.kt +++ /dev/null @@ -1,5 +0,0 @@ -package dev.freya02.botcommands.restarter.internal.sources - -internal fun interface SourceDirectoryListener { - fun onChange(sourcesFilesFactory: () -> SourceFiles) -} diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceFile.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceFile.kt index 1814457f8..462c60345 100644 --- a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceFile.kt +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceFile.kt @@ -6,10 +6,6 @@ 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") -} +) : ISourceFile internal object DeletedSourceFile : ISourceFile diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/watcher/ClasspathListener.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/watcher/ClasspathListener.kt deleted file mode 100644 index 2aa0c64bc..000000000 --- a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/watcher/ClasspathListener.kt +++ /dev/null @@ -1,42 +0,0 @@ -package dev.freya02.botcommands.restarter.internal.watcher - -import dev.freya02.botcommands.restarter.internal.Restarter -import dev.freya02.botcommands.restarter.internal.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() - } -} diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/watcher/ClasspathWatcher.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/watcher/ClasspathWatcher.kt index c79b94922..6adddd3c3 100644 --- a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/watcher/ClasspathWatcher.kt +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/watcher/ClasspathWatcher.kt @@ -28,7 +28,6 @@ import kotlin.time.Duration private val logger = KotlinLogging.logger { } -// Lightweight, singleton version of [[SourceDirectories]] + [[ClasspathListener]] internal class ClasspathWatcher private constructor( settings: Settings, ) { From d215a82444b110d25de22de6c9b5e35877a0a467 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:50:37 +0100 Subject: [PATCH 06/11] Rename config file --- .../api/config/{BRestartConfig.kt => RestarterConfig.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/api/config/{BRestartConfig.kt => RestarterConfig.kt} (100%) diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/api/config/BRestartConfig.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/api/config/RestarterConfig.kt similarity index 100% rename from BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/api/config/BRestartConfig.kt rename to BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/api/config/RestarterConfig.kt From 164da303f8d600513cb579511def50a026c4fae5 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Wed, 11 Feb 2026 17:13:45 +0100 Subject: [PATCH 07/11] Add README.md --- BotCommands-restarter/README.md | 69 +++++++++++++++++++++++++++++++++ README.md | 1 + 2 files changed, 70 insertions(+) create mode 100644 BotCommands-restarter/README.md diff --git a/BotCommands-restarter/README.md b/BotCommands-restarter/README.md new file mode 100644 index 000000000..49e9c9ffe --- /dev/null +++ b/BotCommands-restarter/README.md @@ -0,0 +1,69 @@ +# BotCommands module - Hot restarter +This module enables fast restarts of your bot as you develop it. + +When you build changes of your code, it restarts automatically, in the same JVM, +leading to much faster restarts, as it doesn't need to recompile most of the code. + +> [!WARNING] +> If you are using Spring, use [`spring-boot-devtools`](https://docs.spring.io/spring-boot/reference/using/devtools.html) instead. + +## Installing + +### Maven +```xml + + + io.github.freya022 + BotCommands-restarter + VERSION + + +``` + +### Gradle +```gradle +repositories { + mavenCentral() +} + +dependencies { + implementation("io.github.freya022:BotCommands-restarter:VERSION") +} +``` + +### Snapshots + +To use the latest, unreleased changes, see [SNAPSHOTS.md](../SNAPSHOTS.md). + +## Usage +You can enable the feature by doing so, after which, every build will restart your application. + +### Kotlin +```kotlin +fun main(args: Array) { + // ... + BotCommands.create { + // ... + + @OptIn(ExperimentalRestartApi::class) + withRestarter(args) { + // Optional configuration + } + } +} +``` + +### Java +```java +void main(String[] args) { + // ... + BotCommands.create(config -> { + // ... + + var restarterConfig = RestarterConfigBuilder.create(args) + // Optional configuration + .build(); + config.withConfig(restarterConfig); + }); +} +``` diff --git a/README.md b/README.md index 57bb63f8b..96ef1a832 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,7 @@ The base `BotCommands` artifact will include modules often used, while others ar - [`BotCommands-spring`](./BotCommands-spring): Support for Spring Boot - [`BotCommands-typesafe-messages`](./BotCommands-typesafe-messages): Allows defining functions to retrieve text content from your bundles, providing better ergonomics and safety with load-time validation - [`BotCommands-method-accessors-classfile`](./BotCommands-method-accessors): Improved alternative for this framework to call your functions +- [`BotCommands-restarter`](./BotCommands-restarter): Enables fast restarts of your bot as you develop it ## Sample usage Here is how you would create a slash command that sends a message in a specified channel. From c3e369119bdffac8f4579368dd1461f198440630 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Thu, 12 Feb 2026 19:12:45 +0100 Subject: [PATCH 08/11] Clear cache of method accessors on restart The keys matched between restarts and wrongly executed pre-restart instances --- .../method/accessors/MethodAccessorFactoryProvider.kt | 8 +++++++- .../internal/core/service/AbstractBotCommandsBootstrap.kt | 3 +++ .../accessors/internal/CachingMethodAccessorFactory.kt | 4 ++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/method/accessors/MethodAccessorFactoryProvider.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/method/accessors/MethodAccessorFactoryProvider.kt index 21c26ec9b..1adabaffa 100644 --- a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/method/accessors/MethodAccessorFactoryProvider.kt +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/method/accessors/MethodAccessorFactoryProvider.kt @@ -11,7 +11,7 @@ import kotlin.reflect.KFunction internal object MethodAccessorFactoryProvider { - private lateinit var accessorFactory: MethodAccessorFactory + private lateinit var accessorFactory: CachingMethodAccessorFactory private val staticAccessors: MutableMap, MethodAccessor<*>> = hashMapOf() internal fun getAccessorFactory(): MethodAccessorFactory { @@ -26,6 +26,12 @@ internal object MethodAccessorFactoryProvider { return accessorFactory } + internal fun clearCache() { + if (::accessorFactory.isInitialized) { + accessorFactory.clearCache() + } + } + @OptIn(ExperimentalMethodAccessorsApi::class) private fun loadAccessorFactory(): MethodAccessorFactory { if (MethodAccessorsConfig.preferClassFileAccessors) { diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/AbstractBotCommandsBootstrap.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/AbstractBotCommandsBootstrap.kt index 4bbc7eec7..c6ed73752 100644 --- a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/AbstractBotCommandsBootstrap.kt +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/AbstractBotCommandsBootstrap.kt @@ -9,6 +9,7 @@ import io.github.freya022.botcommands.api.core.events.PreLoadEvent import io.github.freya022.botcommands.api.core.objectLogger import io.github.freya022.botcommands.api.core.service.getService import io.github.freya022.botcommands.internal.core.BContextImpl +import io.github.freya022.botcommands.internal.core.method.accessors.MethodAccessorFactoryProvider import io.github.freya022.botcommands.internal.utils.ReflectionMetadata import kotlinx.coroutines.runBlocking import kotlin.time.DurationUnit @@ -18,6 +19,8 @@ abstract class AbstractBotCommandsBootstrap(protected val config: BConfig) : Bot protected val logger = objectLogger() protected fun init() { + MethodAccessorFactoryProvider.clearCache() + measure("Scanned reflection metadata") { ReflectionMetadata.runScan(config, this) } diff --git a/BotCommands-method-accessors/core/src/main/kotlin/dev/freya02/botcommands/method/accessors/internal/CachingMethodAccessorFactory.kt b/BotCommands-method-accessors/core/src/main/kotlin/dev/freya02/botcommands/method/accessors/internal/CachingMethodAccessorFactory.kt index b24864cce..dc4c75d7b 100644 --- a/BotCommands-method-accessors/core/src/main/kotlin/dev/freya02/botcommands/method/accessors/internal/CachingMethodAccessorFactory.kt +++ b/BotCommands-method-accessors/core/src/main/kotlin/dev/freya02/botcommands/method/accessors/internal/CachingMethodAccessorFactory.kt @@ -13,6 +13,10 @@ class CachingMethodAccessorFactory(private val delegate: MethodAccessorFactory) private val cache = WeakHashMap>() private val lock = ReentrantLock() + fun clearCache() { + cache.clear() + } + override fun create( instance: Any?, function: KFunction, From 3f2bbca07d2e23c31342d1aa3e852168834bce92 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Sun, 15 Feb 2026 16:49:04 +0100 Subject: [PATCH 09/11] Update config --- BotCommands-restarter/README.md | 6 +-- .../restarter/api/config/RestarterConfig.kt | 40 +++++++++++++------ 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/BotCommands-restarter/README.md b/BotCommands-restarter/README.md index 49e9c9ffe..acac89ccc 100644 --- a/BotCommands-restarter/README.md +++ b/BotCommands-restarter/README.md @@ -46,7 +46,7 @@ fun main(args: Array) { // ... @OptIn(ExperimentalRestartApi::class) - withRestarter(args) { + registerRestarter(args) { // Optional configuration } } @@ -60,10 +60,10 @@ void main(String[] args) { BotCommands.create(config -> { // ... - var restarterConfig = RestarterConfigBuilder.create(args) + var restarterConfig = RestarterConfig.builder(args) // Optional configuration .build(); - config.withConfig(restarterConfig); + config.registerModule(restarterConfig); }); } ``` diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/api/config/RestarterConfig.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/api/config/RestarterConfig.kt index 322ad4760..73f04fdad 100644 --- a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/api/config/RestarterConfig.kt +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/api/config/RestarterConfig.kt @@ -40,12 +40,29 @@ interface RestarterConfigProps { } /** - * @see [RestarterConfigBuilder] + * Configuration for the restarter feature. + * + * To enable this feature, a configuration of it must be registered. + * + * @see [RestarterConfig.builder] + * @see [registerRestarter] */ @InjectedService @ExperimentalRestartApi interface RestarterConfig : IConfig, RestarterConfigProps { + override val configType get() = RestarterConfig::class.java + + companion object { + /** + * Creates a new [RestarterConfigBuilder], you must [build] it and [register][BConfigBuilder.registerModule] it. + * + * @param args The program arguments, they will be passed to the main method upon restarting + */ + fun builder(args: Array): RestarterConfigBuilder { + return RestarterConfigBuilder.create(args) + } + } } @ExperimentalRestartApi @@ -78,37 +95,34 @@ class RestarterConfigBuilder private constructor( } /** - * Builds the [RestarterConfig], you can register the built configuration with [BConfigBuilder.withConfig]. + * Builds the [RestarterConfig], you can register the built configuration with [BConfigBuilder.registerModule]. */ fun build(): RestarterConfig = object : RestarterConfig { override val startArgs = this@RestarterConfigBuilder.startArgs override val restartDelay = this@RestarterConfigBuilder.restartDelay } - companion object { + internal companion object { - /** - * Creates a new [RestarterConfigBuilder], you must [build] it and [register][BConfigBuilder.withConfig] it. - * - * @param args The program arguments, they will be passed to the main method upon restarting - */ - @JvmStatic - fun create(args: Array): RestarterConfigBuilder { + @JvmSynthetic + internal fun create(args: Array): RestarterConfigBuilder { return RestarterConfigBuilder(args.toList()) } } } /** - * Registers the restarter module. + * Registers the restarter module, enabling the feature. * * @param args The program arguments, they will be passed to the main method upon restarting * @param block A block for further configuration + * + * @throws IllegalStateException If the module was already registered */ @ExperimentalRestartApi -fun BConfigBuilder.withRestarter(args: Array, block: RestarterConfigBuilder.() -> Unit = { }) { +fun BConfigBuilder.registerRestarter(args: Array, block: RestarterConfigBuilder.() -> Unit = { }) { val config = RestarterConfigBuilder.create(args) .apply(block) .build() - withConfig(config) + registerModule(config) } From 9a858e6a84bb6ffc75130712156810ca7ab5aeb7 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Sun, 15 Feb 2026 16:49:12 +0100 Subject: [PATCH 10/11] Add missing badge --- BotCommands-restarter/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/BotCommands-restarter/README.md b/BotCommands-restarter/README.md index acac89ccc..7ca9c85fa 100644 --- a/BotCommands-restarter/README.md +++ b/BotCommands-restarter/README.md @@ -1,3 +1,6 @@ +[bc-module-maven-central-shield]: https://img.shields.io/maven-central/v/io.github.freya022/BotCommands-restarter?label=Maven%20central&logo=apachemaven&versionPrefix=3 +[bc-module-maven-central-link]: https://central.sonatype.com/artifact/io.github.freya022/BotCommands-restarter + # BotCommands module - Hot restarter This module enables fast restarts of your bot as you develop it. @@ -8,6 +11,7 @@ leading to much faster restarts, as it doesn't need to recompile most of the cod > If you are using Spring, use [`spring-boot-devtools`](https://docs.spring.io/spring-boot/reference/using/devtools.html) instead. ## Installing +[![BotCommands-restarter on maven central][bc-module-maven-central-shield] ][bc-module-maven-central-link] ### Maven ```xml From bbda95ad32395c0bfdeb3a6b85b39ba01c40efaf Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:42:34 +0100 Subject: [PATCH 11/11] Reset AppEmojisLoader on (re)start So it won't be mistaken as loaded after a restart --- .../internal/core/service/AbstractBotCommandsBootstrap.kt | 2 ++ .../botcommands/internal/emojis/AppEmojiContainerProcessor.kt | 4 +--- .../freya022/botcommands/internal/emojis/AppEmojisLoader.kt | 2 -- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/AbstractBotCommandsBootstrap.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/AbstractBotCommandsBootstrap.kt index c6ed73752..9e4b49d49 100644 --- a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/AbstractBotCommandsBootstrap.kt +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/AbstractBotCommandsBootstrap.kt @@ -10,6 +10,7 @@ import io.github.freya022.botcommands.api.core.objectLogger import io.github.freya022.botcommands.api.core.service.getService import io.github.freya022.botcommands.internal.core.BContextImpl import io.github.freya022.botcommands.internal.core.method.accessors.MethodAccessorFactoryProvider +import io.github.freya022.botcommands.internal.emojis.AppEmojisLoader import io.github.freya022.botcommands.internal.utils.ReflectionMetadata import kotlinx.coroutines.runBlocking import kotlin.time.DurationUnit @@ -20,6 +21,7 @@ abstract class AbstractBotCommandsBootstrap(protected val config: BConfig) : Bot protected fun init() { MethodAccessorFactoryProvider.clearCache() + AppEmojisLoader.clear() measure("Scanned reflection metadata") { ReflectionMetadata.runScan(config, this) diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/emojis/AppEmojiContainerProcessor.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/emojis/AppEmojiContainerProcessor.kt index 6557935c1..52e7ba774 100644 --- a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/emojis/AppEmojiContainerProcessor.kt +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/emojis/AppEmojiContainerProcessor.kt @@ -5,7 +5,6 @@ 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.utils.findAnnotationRecursive import io.github.freya022.botcommands.api.emojis.annotations.AppEmojiContainer -import org.jetbrains.annotations.TestOnly import kotlin.reflect.KClass internal object AppEmojiContainerProcessor : ClassGraphProcessor { @@ -23,8 +22,7 @@ internal object AppEmojiContainerProcessor : ClassGraphProcessor { } } - @TestOnly internal fun clear() { emojiClasses.clear() } -} \ No newline at end of file +} diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/emojis/AppEmojisLoader.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/emojis/AppEmojisLoader.kt index cb8a7ba22..a42ca58a5 100644 --- a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/emojis/AppEmojisLoader.kt +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/emojis/AppEmojisLoader.kt @@ -23,7 +23,6 @@ import net.dv8tion.jda.api.JDA import net.dv8tion.jda.api.entities.Icon import net.dv8tion.jda.api.entities.emoji.ApplicationEmoji import net.dv8tion.jda.internal.utils.Checks -import org.jetbrains.annotations.TestOnly import kotlin.math.abs import kotlin.reflect.KProperty import kotlin.reflect.full.declaredMemberProperties @@ -235,7 +234,6 @@ internal class AppEmojisLoader internal constructor( private val toLoad = arrayListOf() private val loadedEmojis = hashMapOf() - @TestOnly internal fun clear() { loaded = false toLoadEmojiNames.clear()