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) +} 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..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 @@ -9,6 +9,8 @@ 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.emojis.AppEmojisLoader import io.github.freya022.botcommands.internal.utils.ReflectionMetadata import kotlinx.coroutines.runBlocking import kotlin.time.DurationUnit @@ -18,6 +20,9 @@ abstract class AbstractBotCommandsBootstrap(protected val config: BConfig) : Bot protected val logger = objectLogger() 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() 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, diff --git a/BotCommands-restarter/README.md b/BotCommands-restarter/README.md new file mode 100644 index 000000000..7ca9c85fa --- /dev/null +++ b/BotCommands-restarter/README.md @@ -0,0 +1,73 @@ +[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. + +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 +[![BotCommands-restarter on maven central][bc-module-maven-central-shield] ][bc-module-maven-central-link] + +### 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) + registerRestarter(args) { + // Optional configuration + } + } +} +``` + +### Java +```java +void main(String[] args) { + // ... + BotCommands.create(config -> { + // ... + + var restarterConfig = RestarterConfig.builder(args) + // Optional configuration + .build(); + config.registerModule(restarterConfig); + }); +} +``` 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/RestarterConfig.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/api/config/RestarterConfig.kt new file mode 100644 index 000000000..73f04fdad --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/api/config/RestarterConfig.kt @@ -0,0 +1,128 @@ +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() +} + +/** + * 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 +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.registerModule]. + */ + fun build(): RestarterConfig = object : RestarterConfig { + override val startArgs = this@RestarterConfigBuilder.startArgs + override val restartDelay = this@RestarterConfigBuilder.restartDelay + } + + internal companion object { + + @JvmSynthetic + internal fun create(args: Array): RestarterConfigBuilder { + return RestarterConfigBuilder(args.toList()) + } + } +} + +/** + * 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.registerRestarter(args: Array, block: RestarterConfigBuilder.() -> Unit = { }) { + val config = RestarterConfigBuilder.create(args) + .apply(block) + .build() + registerModule(config) +} 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/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..7addfde88 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/RestartClassLoader.kt @@ -0,0 +1,44 @@ +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) + } + + internal fun publicDefineClass(name: String, bytes: ByteArray): Class<*> { + return defineClass(name, bytes, 0, bytes.size) + } +} 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/SourceFile.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceFile.kt new file mode 100644 index 000000000..462c60345 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/sources/SourceFile.kt @@ -0,0 +1,11 @@ +package dev.freya02.botcommands.restarter.internal.sources + +import java.time.Instant + +internal sealed interface ISourceFile + +internal class SourceFile( + val lastModified: Instant, +) : ISourceFile + +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/ClasspathWatcher.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/watcher/ClasspathWatcher.kt new file mode 100644 index 000000000..6adddd3c3 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/restarter/internal/watcher/ClasspathWatcher.kt @@ -0,0 +1,229 @@ +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 { } + +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/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 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. 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") 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