diff --git a/BotCommands-jda-keepalive/build.gradle.kts b/BotCommands-jda-keepalive/build.gradle.kts new file mode 100644 index 000000000..28eabf2ab --- /dev/null +++ b/BotCommands-jda-keepalive/build.gradle.kts @@ -0,0 +1,74 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("BotCommands-conventions") + id("BotCommands-publish-conventions") +} + +dependencies { + api(projects.botCommands) + + // Logging + implementation(libs.kotlin.logging) + + // -------------------- TEST DEPENDENCIES -------------------- + + testImplementation(projects.botCommandsRestarter) + testImplementation(libs.mockk) + testImplementation(libs.bytebuddy) + testImplementation(libs.logback.classic) +} + +fun registerSourceSet(name: String, extendsTestDependencies: Boolean) { + sourceSets { + register(name) { + compileClasspath += sourceSets.main.get().output + runtimeClasspath += sourceSets.main.get().output + } + } + + configurations["${name}Api"].extendsFrom(configurations["api"]) + configurations["${name}Implementation"].extendsFrom(configurations["implementation"]) + configurations["${name}CompileOnly"].extendsFrom(configurations["compileOnly"]) + + if (extendsTestDependencies) { + configurations["${name}Api"].extendsFrom(configurations["testApi"]) + configurations["${name}Implementation"].extendsFrom(configurations["testImplementation"]) + configurations["${name}CompileOnly"].extendsFrom(configurations["testCompileOnly"]) + } +} + +// Register other source sets +registerSourceSet(name = "testBot", extendsTestDependencies = true) + +java { + sourceCompatibility = JavaVersion.VERSION_24 + targetCompatibility = JavaVersion.VERSION_24 +} + +kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_24 + + freeCompilerArgs.addAll( + "-Xcontext-parameters", + ) + } +} + +val jar by tasks.getting(Jar::class) { + manifest { + attributes( + "Premain-Class" to "dev.freya02.botcommands.jda.keepalive.internal.Agent", + "Agent-Class" to "dev.freya02.botcommands.jda.keepalive.internal.Agent", + ) + } +} + +tasks.withType { + useJUnitPlatform() + + // Don't use "-javaagent" because [[AgentTest]] requires loading classes before the agent transforms them + jvmArgs("-Djdk.attach.allowAttachSelf=true") + jvmArgs("-Dbc.jda.keepalive.agentPath=${jar.archiveFile.get().asFile.absolutePath}") +} diff --git a/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/api/JDAKeepAlive.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/api/JDAKeepAlive.kt new file mode 100644 index 000000000..fd8564dc8 --- /dev/null +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/api/JDAKeepAlive.kt @@ -0,0 +1,11 @@ +package dev.freya02.botcommands.jda.keepalive.api + +import dev.freya02.botcommands.jda.keepalive.internal.Agent + +object JDAKeepAlive { + + @JvmStatic + fun install() { + Agent.load() + } +} diff --git a/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/Agent.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/Agent.kt new file mode 100644 index 000000000..e7375d659 --- /dev/null +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/Agent.kt @@ -0,0 +1,99 @@ +package dev.freya02.botcommands.jda.keepalive.internal + +import com.sun.tools.attach.VirtualMachine +import dev.freya02.botcommands.jda.keepalive.internal.exceptions.AlreadyLoadedClassesException +import dev.freya02.botcommands.jda.keepalive.internal.exceptions.AttachSelfDeniedException +import dev.freya02.botcommands.jda.keepalive.internal.exceptions.IllegalAgentContainerException +import dev.freya02.botcommands.jda.keepalive.internal.transformer.* +import io.github.freya022.botcommands.api.core.utils.joinAsList +import java.lang.instrument.Instrumentation +import java.lang.management.ManagementFactory +import java.nio.file.Path +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock +import kotlin.io.path.Path +import kotlin.io.path.absolutePathString +import kotlin.io.path.extension +import kotlin.io.path.toPath + +internal object Agent { + + internal val transformers = mapOf( + CD_JDABuilder to JDABuilderTransformer, + CD_JDAImpl to JDAImplTransformer, + CD_JDAService to JDAServiceTransformer, + CD_BContextImpl to BContextImplTransformer, + ) + + private val lock = ReentrantLock() + internal var isLoaded = false + private set + + @JvmStatic + fun premain(agentArgs: String?, inst: Instrumentation) { + lock.withLock { + isLoaded = true + } + + transformers.values.forEach(inst::addTransformer) + } + + @JvmStatic + fun agentmain(agentArgs: String?, inst: Instrumentation) { + lock.withLock { + if (isLoaded) return + isLoaded = true + } + + checkNoLoadedClassesAreToBeTransformed(inst.allLoadedClasses) + + transformers.values.forEach(inst::addTransformer) + } + + internal fun checkNoLoadedClassesAreToBeTransformed(allLoadedClasses: Array>) { + val transformedClasses = transformers.keys.mapTo(hashSetOf()) { it.packageName() + "." + it.displayName() } + val earlyLoadedClasses = allLoadedClasses.filter { it.name in transformedClasses } + if (earlyLoadedClasses.isNotEmpty()) { + // TODO wiki link (of the whole loading mechanism probably) + throw AlreadyLoadedClassesException( + "Dynamically loaded agents must be loaded before the classes it transforms, although it is recommended to add the agent via the command line, offending classes:\n" + + earlyLoadedClasses.joinAsList { it.name } + ) + } + } + + internal fun load() { + lock.withLock { + if (isLoaded) return + } + + // Check self-attaching agents are allowed + if (System.getProperty("jdk.attach.allowAttachSelf") != "true") { + // TODO wiki link (show how to do in IJ) + throw AttachSelfDeniedException("Can only dynamically load an agent with the '-Djdk.attach.allowAttachSelf=true' VM argument") + } + + // Get the agent JAR + // In a user's dev environment, this should be the dependency's JAR + // but in *our* test environment, we need a property to the JAR produced by Gradle, + // as it still uses the directory in the classpath + val agentSourcePath: Path = run { + // Property to test instrumentation is applied + System.getProperty("bc.jda.keepalive.agentPath")?.let { return@run Path(it) } + + javaClass.protectionDomain.codeSource.location.toURI().toPath() + } + if (agentSourcePath.extension != "jar") { + // TODO add wiki link about including the dependency only in dev envs + throw IllegalAgentContainerException( + "Can only dynamically load an agent using a JAR, this agent should only be loaded in development, please see the wiki\n" + + "Agent source @ ${agentSourcePath.absolutePathString()}" + ) + } + + // Load agent on ourselves + val jvm = VirtualMachine.attach(ManagementFactory.getRuntimeMXBean().pid.toString()) + jvm.loadAgent(agentSourcePath.absolutePathString()) + jvm.detach() + } +} diff --git a/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/BufferingEventManager.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/BufferingEventManager.kt new file mode 100644 index 000000000..98eb26703 --- /dev/null +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/BufferingEventManager.kt @@ -0,0 +1,67 @@ +package dev.freya02.botcommands.jda.keepalive.internal + +import net.dv8tion.jda.api.events.GenericEvent +import net.dv8tion.jda.api.hooks.IEventManager +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +internal class BufferingEventManager @DynamicCall constructor( + delegate: IEventManager, +) : IEventManager { + + private val lock = ReentrantLock() + private val eventBuffer: MutableList = arrayListOf() + + private var delegate: IEventManager? = delegate + + internal fun setDelegate(delegate: IEventManager) { + lock.withLock { + check(delegate !is BufferingEventManager) { + "Tried to delegate to a BufferingEventManager!" + } + + this.delegate = delegate + eventBuffer.forEach(::handle) + } + } + + internal fun detach() { + lock.withLock { + delegate = null + } + } + + override fun register(listener: Any) { + lock.withLock { + val delegate = delegate ?: error("Should not happen, implement a listener queue if necessary") + delegate.register(listener) + } + } + + override fun unregister(listener: Any) { + lock.withLock { + val delegate = delegate ?: error("Should not happen, implement a listener queue if necessary") + delegate.unregister(listener) + } + } + + override fun handle(event: GenericEvent) { + val delegate = lock.withLock { + val delegate = delegate + if (delegate == null) { + eventBuffer += event + return + } + delegate + } + + delegate.handle(event) + } + + override fun getRegisteredListeners(): List { + lock.withLock { + val delegate = delegate ?: error("Should not happen, implement a listener queue if necessary") + return delegate.registeredListeners + } + } +} diff --git a/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/DynamicCall.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/DynamicCall.kt new file mode 100644 index 000000000..89afd0a4f --- /dev/null +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/DynamicCall.kt @@ -0,0 +1,10 @@ +package dev.freya02.botcommands.jda.keepalive.internal + +/** + * This member is used by generated code and as such is not directly referenced. + * + * This member must be `public`. + */ +@Retention(AnnotationRetention.SOURCE) +@Target(AnnotationTarget.CONSTRUCTOR, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER, AnnotationTarget.PROPERTY_SETTER) +internal annotation class DynamicCall diff --git a/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/JDABuilderConfiguration.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/JDABuilderConfiguration.kt new file mode 100644 index 000000000..4e8ca2348 --- /dev/null +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/JDABuilderConfiguration.kt @@ -0,0 +1,130 @@ +package dev.freya02.botcommands.jda.keepalive.internal + +import io.github.freya022.botcommands.api.core.utils.enumSetOf +import io.github.freya022.botcommands.api.core.utils.enumSetOfAll +import io.github.oshai.kotlinlogging.KotlinLogging +import net.dv8tion.jda.api.OnlineStatus +import net.dv8tion.jda.api.entities.Activity +import net.dv8tion.jda.api.hooks.IEventManager +import net.dv8tion.jda.api.hooks.InterfacedEventManager +import net.dv8tion.jda.api.utils.ChunkingFilter +import net.dv8tion.jda.api.utils.MemberCachePolicy +import net.dv8tion.jda.api.utils.cache.CacheFlag +import java.util.* + +private val logger = KotlinLogging.logger { } + +class JDABuilderConfiguration internal constructor() { + + private val warnedUnsupportedValues: MutableSet = hashSetOf() + + var hasUnsupportedValues = false + private set + + private val builderValues: MutableMap = hashMapOf() + private var _eventManager: IEventManager? = null + val eventManager: IEventManager get() = _eventManager ?: InterfacedEventManager() + + // So we can track the initial token and intents, the constructor will be instrumented and call this method + // The user overriding the values using token/intent setters should not be an issue + @DynamicCall + fun onInit(token: String?, intents: Int) { + builderValues[ValueType.TOKEN] = token + builderValues[ValueType.INTENTS] = intents + builderValues[ValueType.CACHE_FLAGS] = enumSetOfAll() + } + + @DynamicCall + fun markUnsupportedValue(signature: String) { + if (warnedUnsupportedValues.add(signature)) + logger.warn { "Unsupported JDABuilder method '$signature', JDA will not be cached between restarts" } + hasUnsupportedValues = true + } + + @DynamicCall + fun setStatus(status: OnlineStatus) { + builderValues[ValueType.STATUS] = status + } + + @DynamicCall + fun setEventManager(eventManager: IEventManager?) { + _eventManager = eventManager + } + + @DynamicCall + fun setEventPassthrough(enable: Boolean) { + builderValues[ValueType.EVENT_PASSTHROUGH] = enable + } + + @DynamicCall + @Suppress("UNCHECKED_CAST") + fun enableCache(first: CacheFlag, vararg others: CacheFlag) { + (builderValues[ValueType.CACHE_FLAGS] as EnumSet) += enumSetOf(first, *others) + } + + @DynamicCall + @Suppress("UNCHECKED_CAST") + fun enableCache(flags: Collection) { + (builderValues[ValueType.CACHE_FLAGS] as EnumSet) += flags + } + + @DynamicCall + @Suppress("UNCHECKED_CAST") + fun disableCache(first: CacheFlag, vararg others: CacheFlag) { + (builderValues[ValueType.CACHE_FLAGS] as EnumSet) -= enumSetOf(first, *others) + } + + @DynamicCall + @Suppress("UNCHECKED_CAST") + fun disableCache(flags: Collection) { + (builderValues[ValueType.CACHE_FLAGS] as EnumSet) -= flags + } + + @DynamicCall + fun setMemberCachePolicy(memberCachePolicy: MemberCachePolicy?) { + builderValues[ValueType.MEMBER_CACHE_POLICY] = memberCachePolicy + } + + @DynamicCall + fun setChunkingFilter(filter: ChunkingFilter?) { + builderValues[ValueType.CHUNKING_FILTER] = filter + } + + @DynamicCall + fun setLargeThreshold(threshold: Int) { + builderValues[ValueType.LARGE_THRESHOLD] = threshold + } + + @DynamicCall + fun setActivity(activity: Activity?) { + builderValues[ValueType.ACTIVITY] = activity + } + + @DynamicCall + fun setEnableShutdownHook(enable: Boolean) { + builderValues[ValueType.ENABLE_SHUTDOWN_HOOK] = enable + } + + internal infix fun isSameAs(other: JDABuilderConfiguration): Boolean { + if (hasUnsupportedValues) return false + if (other.hasUnsupportedValues) return false + + return builderValues == other.builderValues + } + + private enum class ValueType { + TOKEN, + INTENTS, + STATUS, + EVENT_PASSTHROUGH, + CACHE_FLAGS, + // These two are interfaces, it's fine to compare them by equality, + // their reference will be the same as they are from the app class loader, + // so if two runs uses MemberCachePolicy#VOICE, it'll still be compatible + MEMBER_CACHE_POLICY, + CHUNKING_FILTER, + LARGE_THRESHOLD, + ACTIVITY, + ENABLE_SHUTDOWN_HOOK, + } +} diff --git a/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/JDABuilderSession.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/JDABuilderSession.kt new file mode 100644 index 000000000..789ca3d7e --- /dev/null +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/JDABuilderSession.kt @@ -0,0 +1,175 @@ +package dev.freya02.botcommands.jda.keepalive.internal + +import dev.freya02.botcommands.jda.keepalive.internal.utils.isJvmShuttingDown +import io.github.freya022.botcommands.api.core.BContext +import io.github.oshai.kotlinlogging.KotlinLogging +import net.dv8tion.jda.api.JDA +import net.dv8tion.jda.api.events.StatusChangeEvent +import net.dv8tion.jda.api.events.guild.GuildReadyEvent +import net.dv8tion.jda.api.events.session.ReadyEvent +import java.util.function.Supplier + +private val logger = KotlinLogging.logger { } + +// TODO there may be an issue with REST requests, +// as the instance will not get shut down, the requester will still run any request currently queued +// so we should find a way to cancel the tasks in the rate limiter + +// TODO a similar feature exists at https://github.com/LorittaBot/DeviousJDA/blob/master/src/examples/java/SessionCheckpointAndGatewayResumeExample.kt +// however as it is a JDA fork, users will not be able to use the latest features, +// there is also a risk that the saved data (checkpoint) could miss fields + +// TODO another way of building this feature is to have the user use an external gateway proxy, such as https://github.com/Gelbpunkt/gateway-proxy +// however such a solution introduces a lot of friction, +// requiring to set up JDA manually, though not complicated, but also docker and that container's config +// An hybrid way would require rewriting that proxy, +// so our module can hook into JDA and set the gateway URL to the proxy's +internal class JDABuilderSession private constructor( + @get:DynamicCall val key: String, +) { + + @get:DynamicCall + val configuration = JDABuilderConfiguration() + var wasBuilt: Boolean = false + private set + + private lateinit var scheduleShutdownSignal: ScheduleShutdownSignalWrapper + + // May also be shutdownNow + @DynamicCall + fun onShutdown(instance: JDA, shutdownFunction: Runnable) { + if (isJvmShuttingDown()) { + // "scheduleShutdownSignal" isn't there yet if this shutdown is triggered by JDA's shutdown hook + return shutdownFunction.run() + } + + if (::scheduleShutdownSignal.isInitialized.not()) { + logger.error { "Expected BContextImpl#scheduleShutdownSignal to be called before shutdown, doing a full shut down" } + return shutdownFunction.run() + } + + // Don't save if this configuration has unsupported values + if (configuration.hasUnsupportedValues) { + scheduleShutdownSignal.runFully() + shutdownFunction.run() + return logger.debug { "Discarding JDA instance as the configuration had unsupported values (key '$key')" } + } + + val eventManager = instance.eventManager as? BufferingEventManager + eventManager?.detach() // If the event manager isn't what we expect, it will be logged when attempting to reuse + + JDACache[key] = JDACache.Data(configuration, instance, shutdownFunction, scheduleShutdownSignal) + } + + /** + * Stores the actual code of BContextImpl#scheduleShutdownSignal and the callback it was passed + * + * [scheduleShutdownSignalFunction] will be called with [afterShutdownSignal] if JDA does get shut down, + * but if JDA is reused, only [afterShutdownSignal] is used. + */ + @DynamicCall + fun onScheduleShutdownSignal(scheduleShutdownSignalFunction: Runnable, afterShutdownSignal: () -> Unit) { + this.scheduleShutdownSignal = ScheduleShutdownSignalWrapper(scheduleShutdownSignalFunction, afterShutdownSignal) + } + + @DynamicCall + fun onBuild(buildFunction: Supplier): JDA { + val jda = buildOrReuse(buildFunction) + wasBuilt = true + return jda + } + + private fun buildOrReuse(buildFunction: Supplier): JDA { + val cachedData = JDACache.remove(key) + + fun createNewInstance(): JDA { + val jda = buildFunction.get() + cachedData?.scheduleShutdownSignal?.runFully() + cachedData?.doShutdown?.run() + return jda + } + + if (configuration.hasUnsupportedValues) { + logger.debug { "Configured JDABuilder has unsupported values, building a new JDA instance (key '$key')" } + return createNewInstance() + } + + if (cachedData == null) { + logger.debug { "Creating a new JDA instance (key '$key')" } + return createNewInstance() + } + + if (cachedData.configuration isSameAs configuration) { + logger.debug { "Reusing JDA instance with compatible configuration (key '$key')" } + val jda = cachedData.jda + val eventManager = jda.eventManager as? BufferingEventManager + ?: run { + logger.warn { "Expected a BufferingEventManager but got a ${jda.eventManager.javaClass.name}, creating a new instance" } + return createNewInstance() + } + + cachedData.scheduleShutdownSignal.runAfterShutdownSignal() + + eventManager.setDelegate(configuration.eventManager) + eventManager.handle(StatusChangeEvent(jda, JDA.Status.LOADING_SUBSYSTEMS, JDA.Status.CONNECTED)) + jda.guildCache.forEachUnordered { eventManager.handle(GuildReadyEvent(jda, -1, it)) } + eventManager.handle(ReadyEvent(jda)) + return jda + } else { + logger.debug { "Creating a new JDA instance as its configuration changed (key '$key')" } + return createNewInstance() + } + } + + internal class ScheduleShutdownSignalWrapper internal constructor( + private val scheduleShutdownSignalFunction: Runnable, + private val afterShutdownSignal: () -> Unit + ) { + + internal fun runFully(): Unit = scheduleShutdownSignalFunction.run() + + internal fun runAfterShutdownSignal(): Unit = afterShutdownSignal() + } + + companion object { + // I would store them in a Map, but JDABuilder has no idea what the key is + private val activeSession: ThreadLocal = + ThreadLocal.withInitial { error("No JDABuilderSession exists for this thread") } + + private val sessions: MutableMap = hashMapOf() + + @JvmStatic + @DynamicCall + fun currentSession(): JDABuilderSession = activeSession.get() + + @JvmStatic + @DynamicCall + fun getSession(key: String): JDABuilderSession { + return sessions[key] ?: error("No JDABuilderSession exists for key '$key'") + } + + @JvmStatic + @DynamicCall + fun getCacheKey(context: BContext): String? = context.config.restartConfig.cacheKey + + @JvmStatic + @DynamicCall + fun withBuilderSession( + key: String, + // Use Java function types to make codegen a bit more reliable + block: Runnable + ) { + val session = JDABuilderSession(key) + sessions[key] = session + activeSession.set(session) + try { + block.run() + if (!session.wasBuilt) { + logger.warn { "Could not save/restore any JDA session as none were built" } + } + } finally { + activeSession.remove() + } + } + } +} diff --git a/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/JDACache.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/JDACache.kt new file mode 100644 index 000000000..845cfcfe2 --- /dev/null +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/JDACache.kt @@ -0,0 +1,21 @@ +package dev.freya02.botcommands.jda.keepalive.internal + +import net.dv8tion.jda.api.JDA + +internal object JDACache { + + private val cache: MutableMap = hashMapOf() + + internal operator fun set(key: String, data: Data) { + cache[key] = data + } + + internal fun remove(key: String): Data? = cache.remove(key) + + internal class Data internal constructor( + val configuration: JDABuilderConfiguration, + val jda: JDA, + val doShutdown: Runnable, + val scheduleShutdownSignal: JDABuilderSession.ScheduleShutdownSignalWrapper, + ) +} diff --git a/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/JDAKeepAliveLoadChecker.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/JDAKeepAliveLoadChecker.kt new file mode 100644 index 000000000..f9d7bd2fa --- /dev/null +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/JDAKeepAliveLoadChecker.kt @@ -0,0 +1,19 @@ +package dev.freya02.botcommands.jda.keepalive.internal + +import io.github.freya022.botcommands.api.core.annotations.BEventListener +import io.github.freya022.botcommands.api.core.events.PreLoadEvent +import io.github.freya022.botcommands.api.core.service.annotations.BService +import io.github.oshai.kotlinlogging.KotlinLogging + +private val logger = KotlinLogging.logger { } + +@BService +internal class JDAKeepAliveLoadChecker { + + @BEventListener + fun onPreLoad(event: PreLoadEvent) { + if (!Agent.isLoaded) { + logger.info { "The JDA keepalive module is present but the agent has not been loaded, please check the instructions" } + } + } +} diff --git a/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/exceptions/AlreadyLoadedClassesException.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/exceptions/AlreadyLoadedClassesException.kt new file mode 100644 index 000000000..7892821d7 --- /dev/null +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/exceptions/AlreadyLoadedClassesException.kt @@ -0,0 +1,3 @@ +package dev.freya02.botcommands.jda.keepalive.internal.exceptions + +internal class AlreadyLoadedClassesException(message: String) : IllegalStateException(message) diff --git a/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/exceptions/AttachSelfDeniedException.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/exceptions/AttachSelfDeniedException.kt new file mode 100644 index 000000000..afc05cc2d --- /dev/null +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/exceptions/AttachSelfDeniedException.kt @@ -0,0 +1,3 @@ +package dev.freya02.botcommands.jda.keepalive.internal.exceptions + +internal class AttachSelfDeniedException(message: String) : IllegalStateException(message) diff --git a/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/exceptions/IllegalAgentContainerException.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/exceptions/IllegalAgentContainerException.kt new file mode 100644 index 000000000..5cf19abff --- /dev/null +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/exceptions/IllegalAgentContainerException.kt @@ -0,0 +1,3 @@ +package dev.freya02.botcommands.jda.keepalive.internal.exceptions + +internal class IllegalAgentContainerException(message: String) : IllegalStateException(message) diff --git a/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/AbstractClassFileTransformer.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/AbstractClassFileTransformer.kt new file mode 100644 index 000000000..f4089b0a8 --- /dev/null +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/AbstractClassFileTransformer.kt @@ -0,0 +1,27 @@ +package dev.freya02.botcommands.jda.keepalive.internal.transformer + +import java.lang.instrument.ClassFileTransformer +import java.security.ProtectionDomain + +internal abstract class AbstractClassFileTransformer protected constructor( + private val target: String +) : ClassFileTransformer { + + override fun transform( + loader: ClassLoader?, + className: String, + classBeingRedefined: Class<*>?, + protectionDomain: ProtectionDomain, + classfileBuffer: ByteArray + ): ByteArray? { + if (className == target) return try { + transform(classfileBuffer) + } catch (e: Throwable) { + e.printStackTrace() + null + } + return null + } + + protected abstract fun transform(classData: ByteArray): ByteArray +} diff --git a/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/BContextImplTransformer.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/BContextImplTransformer.kt new file mode 100644 index 000000000..023db2932 --- /dev/null +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/BContextImplTransformer.kt @@ -0,0 +1,98 @@ +package dev.freya02.botcommands.jda.keepalive.internal.transformer + +import dev.freya02.botcommands.jda.keepalive.internal.transformer.utils.* +import io.github.oshai.kotlinlogging.KotlinLogging +import java.lang.classfile.* +import java.lang.constant.ConstantDescs.CD_String +import java.lang.constant.ConstantDescs.CD_void +import java.lang.constant.MethodTypeDesc +import java.lang.reflect.AccessFlag + +private val logger = KotlinLogging.logger { } + +internal object BContextImplTransformer : AbstractClassFileTransformer("io/github/freya022/botcommands/internal/core/BContextImpl") { + + override fun transform(classData: ByteArray): ByteArray { + val classFile = ClassFile.of() + val classModel = classFile.parse(classData) + + return classFile.transformClass( + classModel, + DeferScheduleShutdownSignalTransform(classModel) + ) + } +} + +private class DeferScheduleShutdownSignalTransform(private val classModel: ClassModel) : ContextualClassTransform { + + context(classBuilder: ClassBuilder) + override fun atStartContextual() { + val targetMethod = classModel.findMethod(TARGET_NAME, TARGET_SIGNATURE) + logger.trace { "Moving ${targetMethod.toFullyQualifiedString()} to '$NEW_NAME'" } + targetMethod.transferCodeTo(NEW_NAME) + } + + context(classBuilder: ClassBuilder) + override fun acceptContextual(classElement: ClassElement) { + val methodModel = classElement as? MethodModel ?: return classBuilder.retain(classElement) + if (!methodModel.matches(TARGET_NAME, TARGET_SIGNATURE)) return classBuilder.retain(classElement) + + logger.trace { "Transforming BContextImpl#${TARGET_NAME}${TARGET_SIGNATURE} to defer shutdown signal scheduling" } + classBuilder.transformMethod(methodModel) { methodBuilder, methodElement -> + if (methodElement !is CodeModel) return@transformMethod methodBuilder.retain(methodElement) + + methodBuilder.withFlags(methodModel.flags().flagsMask().withVisibility(AccessFlag.PUBLIC)) + + methodBuilder.withCode { codeBuilder -> + val thisSlot = codeBuilder.receiverSlot() + + val afterShutdownSignalSlot = codeBuilder.parameterSlot(0) + val doScheduleShutdownSignalSlot = codeBuilder.allocateLocal(TypeKind.REFERENCE) + val sessionKeySlot = codeBuilder.allocateLocal(TypeKind.REFERENCE) + val builderSessionSlot = codeBuilder.allocateLocal(TypeKind.REFERENCE) + + // Runnable doScheduleShutdownSignal = () -> this.doScheduleShutdownSignal(afterShutdownSignal) + codeBuilder.aload(thisSlot) + codeBuilder.aload(afterShutdownSignalSlot) + codeBuilder.invokedynamic( + createLambda( + interfaceMethod = Runnable::run, + targetType = CD_BContextImpl, + targetMethod = NEW_NAME, + targetMethodReturnType = CD_void, + targetMethodArguments = listOf(), + capturedTypes = listOf(CD_Function0), + isStatic = false + ) + ) + codeBuilder.astore(doScheduleShutdownSignalSlot) + + // String sessionKey = JDABuilderSession.getCacheKey(this) + codeBuilder.aload(thisSlot) + codeBuilder.invokestatic(CD_JDABuilderSession, "getCacheKey", MethodTypeDesc.of(CD_String, CD_BContext)) + codeBuilder.astore(sessionKeySlot) + + // JDABuilderSession builderSession = JDABuilderSession.getSession(sessionKey) + codeBuilder.aload(sessionKeySlot) + codeBuilder.invokestatic(CD_JDABuilderSession, "getSession", MethodTypeDesc.of(CD_JDABuilderSession, CD_String)) + codeBuilder.astore(builderSessionSlot) + + // builderSession.onScheduleShutdownSignal(doScheduleShutdownSignal, afterShutdownSignal) + codeBuilder.aload(builderSessionSlot) + codeBuilder.aload(doScheduleShutdownSignalSlot) + codeBuilder.aload(afterShutdownSignalSlot) + codeBuilder.invokevirtual(CD_JDABuilderSession, "onScheduleShutdownSignal", MethodTypeDesc.of(CD_void, CD_Runnable, CD_Function0)) + + // Required + codeBuilder.return_() + } + } + } + + private companion object { + const val TARGET_NAME = "scheduleShutdownSignal" + const val TARGET_SIGNATURE = "(Lkotlin/jvm/functions/Function0;)V" + + const val NEW_NAME = "doScheduleShutdownSignal" + } +} diff --git a/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/ClassDescriptors.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/ClassDescriptors.kt new file mode 100644 index 000000000..895a5369f --- /dev/null +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/ClassDescriptors.kt @@ -0,0 +1,32 @@ +package dev.freya02.botcommands.jda.keepalive.internal.transformer + +import dev.freya02.botcommands.jda.keepalive.internal.BufferingEventManager +import dev.freya02.botcommands.jda.keepalive.internal.JDABuilderConfiguration +import dev.freya02.botcommands.jda.keepalive.internal.JDABuilderSession +import dev.freya02.botcommands.jda.keepalive.internal.transformer.utils.classDesc +import org.intellij.lang.annotations.Language +import java.lang.constant.ClassDesc + +internal val CD_Function0 = classDescOf("kotlin.jvm.functions.Function0") + +internal val CD_IllegalStateException = classDescOf("java.lang.IllegalStateException") +internal val CD_Runnable = classDescOf("java.lang.Runnable") +internal val CD_Supplier = classDescOf("java.util.function.Supplier") + +internal val CD_BContext = classDescOf("io.github.freya022.botcommands.api.core.BContext") +internal val CD_BContextImpl = classDescOf("io.github.freya022.botcommands.internal.core.BContextImpl") +internal val CD_JDAService = classDescOf("io.github.freya022.botcommands.api.core.JDAService") +internal val CD_BReadyEvent = classDescOf("io.github.freya022.botcommands.api.core.events.BReadyEvent") + +internal val CD_JDA = classDescOf("net.dv8tion.jda.api.JDA") +internal val CD_JDAImpl = classDescOf("net.dv8tion.jda.internal.JDAImpl") +internal val CD_JDABuilder = classDescOf("net.dv8tion.jda.api.JDABuilder") +internal val CD_IEventManager = classDescOf("net.dv8tion.jda.api.hooks.IEventManager") + +internal val CD_BufferingEventManager = classDesc() +internal val CD_JDABuilderSession = classDesc() +internal val CD_JDABuilderConfiguration = classDesc() + +private fun classDescOf(@Language("java", prefix = "import ", suffix = ";") name: String): ClassDesc { + return ClassDesc.of(name) +} diff --git a/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/ContextualClassTransform.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/ContextualClassTransform.kt new file mode 100644 index 000000000..925ddb414 --- /dev/null +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/ContextualClassTransform.kt @@ -0,0 +1,25 @@ +package dev.freya02.botcommands.jda.keepalive.internal.transformer + +import java.lang.classfile.ClassBuilder +import java.lang.classfile.ClassElement +import java.lang.classfile.ClassTransform + +internal interface ContextualClassTransform : ClassTransform { + + override fun atStart(builder: ClassBuilder): Unit = context(builder) { atStartContextual() } + + context(classBuilder: ClassBuilder) + fun atStartContextual() { } + + + override fun atEnd(builder: ClassBuilder): Unit = context(builder) { atEndContextual() } + + context(classBuilder: ClassBuilder) + fun atEndContextual() { } + + + override fun accept(builder: ClassBuilder, element: ClassElement): Unit = context(builder) { acceptContextual(element) } + + context(classBuilder: ClassBuilder) + fun acceptContextual(classElement: ClassElement) { } +} diff --git a/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/JDABuilderTransformer.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/JDABuilderTransformer.kt new file mode 100644 index 000000000..818b454cf --- /dev/null +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/JDABuilderTransformer.kt @@ -0,0 +1,258 @@ +package dev.freya02.botcommands.jda.keepalive.internal.transformer + +import dev.freya02.botcommands.jda.keepalive.internal.JDABuilderConfiguration +import dev.freya02.botcommands.jda.keepalive.internal.transformer.utils.* +import io.github.oshai.kotlinlogging.KotlinLogging +import java.lang.classfile.* +import java.lang.classfile.ClassFile.* +import java.lang.constant.ClassDesc +import java.lang.constant.ConstantDescs.* +import java.lang.constant.MethodTypeDesc +import java.lang.reflect.AccessFlag +import java.util.function.Supplier + +private val logger = KotlinLogging.logger { } + +internal object JDABuilderTransformer : AbstractClassFileTransformer("net/dv8tion/jda/api/JDABuilder") { + + override fun transform(classData: ByteArray): ByteArray { + val classFile = ClassFile.of() + val classModel = classFile.parse(classData) + + return classFile.transformClass( + classModel, + CaptureSetterParametersTransform() + .andThen(CaptureConstructorParametersTransform(classModel)) + .andThen(DeferBuildAndSetBufferingEventManagerTransform(classModel)) + ) + } +} + +private class CaptureConstructorParametersTransform(private val classModel: ClassModel) : ContextualClassTransform { + + context(classBuilder: ClassBuilder) + override fun atStartContextual() { + classModel.findMethod(TARGET_NAME, TARGET_SIGNATURE) + } + + context(classBuilder: ClassBuilder) + override fun acceptContextual(classElement: ClassElement) { + val methodModel = classElement as? MethodModel ?: return classBuilder.retain(classElement) + if (!methodModel.matches(TARGET_NAME, TARGET_SIGNATURE)) return classBuilder.retain(classElement) + + logger.trace { "Transforming ${methodModel.toFullyQualifiedString()} to capture parameters" } + classBuilder.transformMethod(methodModel) { methodBuilder, methodElement -> + val codeModel = methodElement as? CodeModel ?: return@transformMethod methodBuilder.retain(methodElement) + + methodBuilder.withCode { codeBuilder -> + val builderConfigurationSlot = codeBuilder.allocateLocal(TypeKind.REFERENCE) + val tokenSlot = codeBuilder.parameterSlot(0) + val intentsSlot = codeBuilder.parameterSlot(1) + + // JDABuilderConfiguration configuration = JDABuilderSession.currentSession().getConfiguration(); + codeBuilder.invokestatic(CD_JDABuilderSession, "currentSession", MethodTypeDesc.of(CD_JDABuilderSession)) + codeBuilder.invokevirtual(CD_JDABuilderSession, "getConfiguration", MethodTypeDesc.of(CD_JDABuilderConfiguration)) + codeBuilder.astore(builderConfigurationSlot) + + // configuration.onInit(token, intents); + codeBuilder.aload(builderConfigurationSlot) + codeBuilder.aload(tokenSlot) + codeBuilder.iload(intentsSlot) + codeBuilder.invokevirtual(CD_JDABuilderConfiguration, "onInit", MethodTypeDesc.of(CD_void, CD_String, CD_int)) + + // Add existing instructions + codeModel.forEach { codeBuilder.with(it) } + } + } + } + + private companion object { + const val TARGET_NAME = "" + const val TARGET_SIGNATURE = "(Ljava/lang/String;I)V" + } +} + +private class DeferBuildAndSetBufferingEventManagerTransform(private val classModel: ClassModel) : ContextualClassTransform { + + context(classBuilder: ClassBuilder) + override fun atStartContextual() { + val targetMethod = classModel.findMethod(TARGET_NAME, TARGET_SIGNATURE) + + logger.trace { "Adding JDABuilder#${NEW_NAME}() to set an event manager and build" } + classBuilder.withMethod( + NEW_NAME, + MethodTypeDesc.of(CD_JDA), + ACC_PRIVATE or ACC_SYNTHETIC or ACC_FINAL + ) { methodBuilder -> + val codeModel = targetMethod.code().get() + + methodBuilder.withCode { codeBuilder -> + val thisSlot = codeBuilder.receiverSlot() + + val bufferingEventManagerSlot = codeBuilder.allocateLocal(TypeKind.REFERENCE) + + // JDABuilder's eventManager is null by default, + // however, the framework mandates setting a framework-provided event manager, + // so let's just throw if it is null. + val nullEventManagerLabel = codeBuilder.newLabel() + codeBuilder.aload(thisSlot) + codeBuilder.getfield(CD_JDABuilder, "eventManager", CD_IEventManager) + codeBuilder.ifnull(nullEventManagerLabel) + + // var bufferingEventManager = new BufferingEventManager + codeBuilder.new_(CD_BufferingEventManager) + codeBuilder.astore(bufferingEventManagerSlot) + + // bufferingEventManager.(eventManager) + codeBuilder.aload(bufferingEventManagerSlot) + codeBuilder.aload(thisSlot) + codeBuilder.getfield(CD_JDABuilder, "eventManager", CD_IEventManager) + codeBuilder.invokespecial(CD_BufferingEventManager, "", MethodTypeDesc.of(CD_void, CD_IEventManager)) + + // this.setEventManager(eventManager) + codeBuilder.aload(thisSlot) + codeBuilder.aload(bufferingEventManagerSlot) + codeBuilder.invokevirtual(CD_JDABuilder, "setEventManager", MethodTypeDesc.of(CD_JDABuilder, CD_IEventManager)) + + // Move the build() code to doBuild() + codeModel.forEach { codeBuilder.with(it) } + + // Branch when "eventManager" is null + codeBuilder.labelBinding(nullEventManagerLabel) + + codeBuilder.new_(CD_IllegalStateException) + codeBuilder.dup() + codeBuilder.ldc("The event manager must be set using the one provided in JDAService#createJDA") + codeBuilder.invokespecial(CD_IllegalStateException, "", MethodTypeDesc.of(CD_void, CD_String)) + codeBuilder.athrow() + } + } + } + + context(classBuilder: ClassBuilder) + override fun acceptContextual(classElement: ClassElement) { + val methodModel = classElement as? MethodModel ?: return classBuilder.retain(classElement) + if (!methodModel.matches(TARGET_NAME, TARGET_SIGNATURE)) return classBuilder.retain(classElement) + + logger.trace { "Transforming ${methodModel.toFullyQualifiedString()} to defer calls" } + classBuilder.transformMethod(methodModel) { methodBuilder, methodElement -> + if (methodElement !is CodeModel) return@transformMethod methodBuilder.retain(methodElement) + + methodBuilder.withCode { codeBuilder -> + val thisSlot = codeBuilder.receiverSlot() + + val builderSessionSlot = codeBuilder.allocateLocal(TypeKind.REFERENCE) + val doBuildSlot = codeBuilder.allocateLocal(TypeKind.REFERENCE) + val jdaSlot = codeBuilder.allocateLocal(TypeKind.REFERENCE) + + // Supplier doBuild = this::doBuild + codeBuilder.aload(thisSlot) + + codeBuilder.invokedynamic( + createLambda( + interfaceMethod = Supplier<*>::get, + targetType = CD_JDABuilder, + targetMethod = NEW_NAME, + targetMethodReturnType = CD_JDA, + targetMethodArguments = listOf(), + capturedTypes = emptyList(), + isStatic = false + ) + ) + codeBuilder.astore(doBuildSlot) + + // JDABuilderSession session = JDABuilderSession.currentSession(); + codeBuilder.invokestatic(CD_JDABuilderSession, "currentSession", MethodTypeDesc.of(CD_JDABuilderSession)) + codeBuilder.astore(builderSessionSlot) + + // var jda = session.onBuild(this::doBuild); + codeBuilder.aload(builderSessionSlot) + codeBuilder.aload(doBuildSlot) + codeBuilder.invokevirtual(CD_JDABuilderSession, "onBuild", MethodTypeDesc.of(CD_JDA, CD_Supplier)) + // Again, prefer using a variable for clarity + codeBuilder.astore(jdaSlot) + + codeBuilder.aload(jdaSlot) + codeBuilder.areturn() + } + } + } + + private companion object { + const val TARGET_NAME = "build" + const val TARGET_SIGNATURE = "()Lnet/dv8tion/jda/api/JDA;" + + const val NEW_NAME = "doBuild" + } +} + +private class CaptureSetterParametersTransform : ContextualClassTransform { + + private val builderSessionMethods: Set = ClassFile.of() + .parse(JDABuilderConfiguration::class.java.getResourceAsStream("JDABuilderConfiguration.class")!!.readAllBytes()) + .methods() + .mapTo(hashSetOf(), ::MethodDesc) + + context(classBuilder: ClassBuilder) + override fun acceptContextual(classElement: ClassElement) { + val methodModel = classElement as? MethodModel ?: return classBuilder.retain(classElement) + if (!methodModel.flags().has(AccessFlag.PUBLIC)) return classBuilder.retain(classElement) + if (methodModel.flags().has(AccessFlag.STATIC)) return classBuilder.retain(classElement) + if (methodModel.methodName().stringValue() == "build") return classBuilder.retain(classElement) + + // Log is done later + classBuilder.transformMethod(methodModel) { methodBuilder, methodElement -> + val codeModel = methodElement as? CodeModel ?: return@transformMethod methodBuilder.retain(methodElement) + + val hasBuilderSessionMethod = methodModel.let(::MethodDesc) in builderSessionMethods + methodBuilder.withCode { codeBuilder -> + val builderConfigurationSlot = codeBuilder.allocateLocal(TypeKind.REFERENCE) + + // JDABuilderConfiguration configuration = JDABuilderSession.currentSession().getConfiguration(); + codeBuilder.invokestatic(CD_JDABuilderSession, "currentSession", MethodTypeDesc.of(CD_JDABuilderSession)) + codeBuilder.invokevirtual(CD_JDABuilderSession, "getConfiguration", MethodTypeDesc.of(CD_JDABuilderConfiguration)) + codeBuilder.astore(builderConfigurationSlot) + + val methodName = methodModel.methodName().stringValue() + if (hasBuilderSessionMethod) { + logger.trace { "Registering ${methodModel.toFullyQualifiedString()} as a cache-compatible method" } + + // Set return type to "void" because our method won't return JDABuilder, and it doesn't matter anyway + val methodType = methodModel.methodTypeSymbol().changeReturnType(CD_void) + + // configuration.theMethod(parameters); + codeBuilder.aload(builderConfigurationSlot) + methodType.parameterList().forEachIndexed { index, parameter -> + val typeKind = TypeKind.fromDescriptor(parameter.descriptorString()) + val slot = codeBuilder.parameterSlot(index) + codeBuilder.loadLocal(typeKind, slot) + } + codeBuilder.invokevirtual(CD_JDABuilderConfiguration, methodName, methodType) + } else { + logger.trace { "Skipping ${methodModel.toFullyQualifiedString()} as it does not have an equivalent method handler" } + + val signature = methodName + "(${methodModel.methodTypeSymbol().parameterList().joinToString { it.displayName() }})" + + // configuration.markUnsupportedValue() + codeBuilder.aload(builderConfigurationSlot) + codeBuilder.ldc(signature) + codeBuilder.invokevirtual(CD_JDABuilderConfiguration, "markUnsupportedValue", MethodTypeDesc.of(CD_void, CD_String)) + } + + // Add existing instructions + codeModel.forEach { codeBuilder.with(it) } + } + } + } + + // Utility to match methods using their name and parameters, but not return type + private data class MethodDesc( + val name: String, + val paramTypes: List + ) { + constructor(methodModel: MethodModel) : this( + methodModel.methodName().stringValue(), + methodModel.methodTypeSymbol().parameterList(), + ) + } +} diff --git a/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/JDAImplTransformer.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/JDAImplTransformer.kt new file mode 100644 index 000000000..5a4abc6ee --- /dev/null +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/JDAImplTransformer.kt @@ -0,0 +1,242 @@ +package dev.freya02.botcommands.jda.keepalive.internal.transformer + +import dev.freya02.botcommands.jda.keepalive.internal.transformer.utils.* +import io.github.oshai.kotlinlogging.KotlinLogging +import java.lang.classfile.* +import java.lang.classfile.ClassFile.* +import java.lang.classfile.instruction.InvokeInstruction +import java.lang.constant.ConstantDescs.CD_String +import java.lang.constant.ConstantDescs.CD_void +import java.lang.constant.MethodTypeDesc + +private val logger = KotlinLogging.logger { } + +internal object JDAImplTransformer : AbstractClassFileTransformer("net/dv8tion/jda/internal/JDAImpl") { + + override fun transform(classData: ByteArray): ByteArray { + val classFile = ClassFile.of() + val classModel = classFile.parse(classData) + return classFile.transformClass( + classModel, + CaptureSessionKeyTransform() + .andThen(DeferShutdownTransform(classModel)) + .andThen(DeferShutdownNowTransform(classModel)) + .andThen(AwaitShutdownTransform()) + ) + } +} + +private class CaptureSessionKeyTransform : ContextualClassTransform { + + context(classBuilder: ClassBuilder) + override fun atStartContextual() { + logger.trace { "Adding JDAImpl#${CACHE_KEY_NAME}" } + classBuilder.withField(CACHE_KEY_NAME, CD_String, ACC_PRIVATE or ACC_FINAL) + + logger.trace { "Adding JDAImpl#getBuilderSession()" } + classBuilder.withMethod("getBuilderSession", MethodTypeDesc.of(CD_JDABuilderSession), ACC_PUBLIC) { methodBuilder -> + methodBuilder.withCode { codeBuilder -> + codeBuilder.aload(codeBuilder.receiverSlot()) + codeBuilder.getfield(CD_JDAImpl, CACHE_KEY_NAME, CD_String) + codeBuilder.invokestatic(CD_JDABuilderSession, "getSession", MethodTypeDesc.of(CD_JDABuilderSession, CD_String)) + codeBuilder.areturn() + } + } + } + + context(classBuilder: ClassBuilder) + override fun acceptContextual(classElement: ClassElement) { + val methodModel = classElement as? MethodModel ?: return classBuilder.retain(classElement) + // No need to check the signature, we can assign the field in all constructors + if (!methodModel.methodName().equalsString("")) return classBuilder.retain(classElement) + + logger.trace { "Transforming ${methodModel.toFullyQualifiedString()} to store the session key" } + classBuilder.transformMethod(methodModel) { methodBuilder, methodElement -> + val codeModel = methodElement as? CodeModel ?: return@transformMethod methodBuilder.retain(methodElement) + + methodBuilder.withCode { codeBuilder -> + val thisSlot = codeBuilder.receiverSlot() + + // this.cacheKey = JDABuilderSession.currentSession().getKey() + codeBuilder.aload(thisSlot) + codeBuilder.invokestatic(CD_JDABuilderSession, "currentSession", MethodTypeDesc.of(CD_JDABuilderSession)) + codeBuilder.invokevirtual(CD_JDABuilderSession, "getKey", MethodTypeDesc.of(CD_String)) + codeBuilder.putfield(CD_JDAImpl, CACHE_KEY_NAME, CD_String) + + // Add existing instructions + codeModel.forEach { codeBuilder.with(it) } + } + } + } + + private companion object { + const val CACHE_KEY_NAME = "cacheKey" + } +} + +private class DeferShutdownTransform(private val classModel: ClassModel) : ContextualClassTransform { + + context(classBuilder: ClassBuilder) + override fun atStartContextual() { + val targetMethod = classModel.findMethod(TARGET_NAME, TARGET_SIGNATURE) + + logger.trace { "Moving ${targetMethod.toFullyQualifiedString()} to '$NEW_NAME'" } + targetMethod.transferCodeTo(NEW_NAME) + } + + context(classBuilder: ClassBuilder) + override fun acceptContextual(classElement: ClassElement) { + val methodModel = classElement as? MethodModel ?: return classBuilder.retain(classElement) + if (!methodModel.matches(TARGET_NAME, TARGET_SIGNATURE)) return classBuilder.retain(classElement) + + logger.trace { "Transforming ${methodModel.toFullyQualifiedString()} to defer execution" } + classBuilder.transformMethod(methodModel) { methodBuilder, methodElement -> + if (methodElement !is CodeModel) return@transformMethod methodBuilder.retain(methodElement) + + methodBuilder.withCode { codeBuilder -> + val thisSlot = codeBuilder.receiverSlot() + + val doShutdownSlot = codeBuilder.allocateLocal(TypeKind.REFERENCE) + val builderSessionSlot = codeBuilder.allocateLocal(TypeKind.REFERENCE) + + // Runnable doShutdown = this::doShutdown + codeBuilder.aload(thisSlot) + codeBuilder.invokedynamic( + createLambda( + interfaceMethod = Runnable::run, + targetType = CD_JDAImpl, + targetMethod = NEW_NAME, + targetMethodReturnType = CD_void, + targetMethodArguments = listOf(), + capturedTypes = listOf(), + isStatic = false + ) + ) + codeBuilder.astore(doShutdownSlot) + + // var builderSession = getBuilderSession() + codeBuilder.aload(thisSlot) + codeBuilder.invokevirtual(CD_JDAImpl, "getBuilderSession", MethodTypeDesc.of(CD_JDABuilderSession)) + codeBuilder.astore(builderSessionSlot) + + // builderSession.onShutdown(this, this::doShutdown); + codeBuilder.aload(builderSessionSlot) + codeBuilder.aload(thisSlot) + codeBuilder.aload(doShutdownSlot) + codeBuilder.invokevirtual(CD_JDABuilderSession, "onShutdown", MethodTypeDesc.of(CD_void, CD_JDA, CD_Runnable)) + + codeBuilder.return_() + } + } + } + + private companion object { + const val TARGET_NAME = "shutdown" + const val TARGET_SIGNATURE = "()V" + + const val NEW_NAME = "doShutdown" + } +} + +private class DeferShutdownNowTransform(private val classModel: ClassModel) : ContextualClassTransform { + + context(classBuilder: ClassBuilder) + override fun atStartContextual() { + val targetMethod = classModel.findMethod(TARGET_NAME, TARGET_SIGNATURE) + + logger.trace { "Moving ${targetMethod.toFullyQualifiedString()} to $NEW_NAME, replacing shutdown() with doShutdown()" } + classBuilder.withMethod( + NEW_NAME, + MethodTypeDesc.of(CD_void), + ACC_PRIVATE or ACC_SYNTHETIC or ACC_FINAL + ) { methodBuilder -> + val codeModel = targetMethod.code().get() + + methodBuilder.withCode { codeBuilder -> + // Move the shutdownNow() code to doShutdownNow() + codeModel.forEach { codeElement -> + // Replace shutdown() with doShutdown() so we don't call [[JDABuilderSession#onShutdown]] more than once + if (codeElement is InvokeInstruction && codeElement.name().equalsString("shutdown")) { + require(codeElement.type().equalsString("()V")) + codeBuilder.invokevirtual(codeElement.owner().asSymbol(), "doShutdown", MethodTypeDesc.of(CD_void)) + return@forEach + } + + codeBuilder.with(codeElement) + } + } + } + } + + context(classBuilder: ClassBuilder) + override fun acceptContextual(classElement: ClassElement) { + val methodModel = classElement as? MethodModel ?: return classBuilder.retain(classElement) + if (!methodModel.matches(TARGET_NAME, TARGET_SIGNATURE)) return classBuilder.retain(classElement) + + logger.trace { "Transforming ${methodModel.toFullyQualifiedString()} to defer execution" } + classBuilder.transformMethod(methodModel) { methodBuilder, methodElement -> + if (methodElement !is CodeModel) return@transformMethod methodBuilder.retain(methodElement) + + methodBuilder.withCode { codeBuilder -> + val thisSlot = codeBuilder.receiverSlot() + + val doShutdownNowSlot = codeBuilder.allocateLocal(TypeKind.REFERENCE) + val builderSessionSlot = codeBuilder.allocateLocal(TypeKind.REFERENCE) + + // Runnable doShutdownNow = this::doShutdownNow + codeBuilder.aload(thisSlot) + codeBuilder.invokedynamic( + createLambda( + interfaceMethod = Runnable::run, + targetType = CD_JDAImpl, + targetMethod = NEW_NAME, + targetMethodReturnType = CD_void, + targetMethodArguments = listOf(), + capturedTypes = listOf(), + isStatic = false + ) + ) + codeBuilder.astore(doShutdownNowSlot) + + // var builderSession = getBuilderSession() + codeBuilder.aload(thisSlot) + codeBuilder.invokevirtual(CD_JDAImpl, "getBuilderSession", MethodTypeDesc.of(CD_JDABuilderSession)) + codeBuilder.astore(builderSessionSlot) + + // builderSession.onShutdown(this, this::doShutdownNow); + codeBuilder.aload(builderSessionSlot) + codeBuilder.aload(thisSlot) + codeBuilder.aload(doShutdownNowSlot) + codeBuilder.invokevirtual(CD_JDABuilderSession, "onShutdown", MethodTypeDesc.of(CD_void, CD_JDA, CD_Runnable)) + + codeBuilder.return_() + } + } + } + + private companion object { + const val TARGET_NAME = "shutdownNow" + const val TARGET_SIGNATURE = "()V" + + const val NEW_NAME = "doShutdownNow" + } +} + +private class AwaitShutdownTransform : ContextualClassTransform { + + context(classBuilder: ClassBuilder) + override fun acceptContextual(classElement: ClassElement) { + val methodModel = classElement as? MethodModel ?: return classBuilder.retain(classElement) + if (!methodModel.methodName().equalsString("awaitShutdown")) return classBuilder.retain(classElement) + + logger.trace { "Transforming ${methodModel.toFullyQualifiedString()} to immediately return" } + classBuilder.transformMethod(methodModel) { methodBuilder, methodElement -> + if (methodElement !is CodeModel) return@transformMethod methodBuilder.retain(methodElement) + + methodBuilder.withCode { codeBuilder -> + codeBuilder.iconst_0() + codeBuilder.ireturn() + } + } + } +} diff --git a/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/JDAServiceTransformer.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/JDAServiceTransformer.kt new file mode 100644 index 000000000..202256c4a --- /dev/null +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/JDAServiceTransformer.kt @@ -0,0 +1,114 @@ +package dev.freya02.botcommands.jda.keepalive.internal.transformer + +import dev.freya02.botcommands.jda.keepalive.internal.transformer.utils.* +import io.github.oshai.kotlinlogging.KotlinLogging +import java.lang.classfile.* +import java.lang.constant.ConstantDescs.CD_String +import java.lang.constant.ConstantDescs.CD_void +import java.lang.constant.MethodTypeDesc + +private val logger = KotlinLogging.logger { } + +internal object JDAServiceTransformer : AbstractClassFileTransformer("io/github/freya022/botcommands/api/core/JDAService") { + + override fun transform(classData: ByteArray): ByteArray { + val classFile = ClassFile.of() + val classModel = classFile.parse(classData) + + return classFile.transformClass( + classModel, + WrapOnReadyEventWithJDABuilderSessionTransform(classModel) + ) + } +} + +private class WrapOnReadyEventWithJDABuilderSessionTransform(private val classModel: ClassModel) : ContextualClassTransform { + + context(classBuilder: ClassBuilder) + override fun atStartContextual() { + // Put the original code of onReadyEvent in the lambda, + // it will be fired by JDABuilderSession.withBuilderSession in onReadyEvent + val targetMethod = classModel.findMethod(TARGET_NAME, TARGET_SIGNATURE) + + logger.trace { "Moving ${targetMethod.toFullyQualifiedString()} to '$NEW_NAME'" } + targetMethod.transferCodeTo(NEW_NAME) + } + + context(classBuilder: ClassBuilder) + override fun acceptContextual(classElement: ClassElement) { + val methodModel = classElement as? MethodModel ?: return classBuilder.retain(classElement) + if (!methodModel.matches(TARGET_NAME, TARGET_SIGNATURE)) return classBuilder.retain(classElement) + + logger.trace { "Transforming ${methodModel.toFullyQualifiedString()} to wrap the code in a build session" } + classBuilder.transformMethod(methodModel) { methodBuilder, methodElement -> + if (methodElement !is CodeModel) return@transformMethod methodBuilder.retain(methodElement) + + methodBuilder.withCode { codeBuilder -> + val thisSlot = codeBuilder.receiverSlot() + + val readyEventSlot = codeBuilder.parameterSlot(0) + val eventManagerSlot = codeBuilder.parameterSlot(1) + + val contextSlot = codeBuilder.allocateLocal(TypeKind.REFERENCE) + val sessionKeySlot = codeBuilder.allocateLocal(TypeKind.REFERENCE) + val sessionRunnableSlot = codeBuilder.allocateLocal(TypeKind.REFERENCE) + + // var context = event.getContext() + // We could inline this to avoid a successive store/load, + // but I think using variables is probably a better practice, let's leave the optimization to the VM + codeBuilder.aload(readyEventSlot) + codeBuilder.invokevirtual(CD_BReadyEvent, "getContext", MethodTypeDesc.of(CD_BContext)) + codeBuilder.astore(contextSlot) + + // var key = JDABuilderSession.getCacheKey(context) + codeBuilder.aload(contextSlot) + codeBuilder.invokestatic(CD_JDABuilderSession, "getCacheKey", MethodTypeDesc.of(CD_String, CD_BContext)) + codeBuilder.astore(sessionKeySlot) + + // THE KEY IS NULLABLE + // If it is, then don't make a session + val nullKeyLabel = codeBuilder.newLabel() + + // if (key == null) -> nullKeyLabel + codeBuilder.aload(sessionKeySlot) + codeBuilder.ifnull(nullKeyLabel) + + // Runnable sessionRunnable = () -> [lambdaName](event, eventManager) + codeBuilder.aload(thisSlot) + codeBuilder.aload(readyEventSlot) + codeBuilder.aload(eventManagerSlot) + codeBuilder.invokedynamic( + createLambda( + interfaceMethod = Runnable::run, + targetType = CD_JDAService, + targetMethod = NEW_NAME, + targetMethodReturnType = CD_void, + targetMethodArguments = listOf(), + capturedTypes = listOf(CD_BReadyEvent, CD_IEventManager), + isStatic = false + ) + ) + codeBuilder.astore(sessionRunnableSlot) + + // JDABuilderSession.withBuilderSession(key, sessionRunnable) + codeBuilder.aload(sessionKeySlot) + codeBuilder.aload(sessionRunnableSlot) + codeBuilder.invokestatic(CD_JDABuilderSession, "withBuilderSession", MethodTypeDesc.of(CD_void, CD_String, CD_Runnable)) + + // Required + codeBuilder.return_() + + // nullKeyLabel code + codeBuilder.labelBinding(nullKeyLabel) + codeBuilder.return_() + } + } + } + + private companion object { + const val TARGET_NAME = $$"onReadyEvent$BotCommands" + const val TARGET_SIGNATURE = "(Lio/github/freya022/botcommands/api/core/events/BReadyEvent;Lnet/dv8tion/jda/api/hooks/IEventManager;)V" + + const val NEW_NAME = $$"lambda$onReadyEvent$BotCommands$withBuilderSession" + } +} diff --git a/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/utils/CodeBuilderUtils.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/utils/CodeBuilderUtils.kt new file mode 100644 index 000000000..3ca5e881a --- /dev/null +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/utils/CodeBuilderUtils.kt @@ -0,0 +1,87 @@ +package dev.freya02.botcommands.jda.keepalive.internal.transformer.utils + +import java.lang.classfile.ClassFileBuilder +import java.lang.classfile.ClassFileElement +import java.lang.classfile.CodeBuilder +import java.lang.constant.* +import java.lang.constant.ConstantDescs.CD_String +import java.lang.invoke.* +import kotlin.reflect.KFunction +import kotlin.reflect.jvm.javaMethod + +internal inline fun classDesc(): ClassDesc = ClassDesc.of(T::class.java.name) + +internal fun ClassFileBuilder.retain(element: E) { + with(element) +} + +internal fun CodeBuilder.ldc(string: String) { + ldc(string as java.lang.String) +} + +internal val lambdaMetafactoryDesc = MethodHandleDesc.ofMethod( + DirectMethodHandleDesc.Kind.STATIC, + classDesc(), + "metafactory", + MethodTypeDesc.of( + classDesc(), + classDesc(), + CD_String, + classDesc(), + classDesc(), + classDesc(), + classDesc() + ) +) + +internal fun createLambda( + interfaceMethod: KFunction<*>, + targetType: ClassDesc, + targetMethod: String, + targetMethodReturnType: ClassDesc, + targetMethodArguments: List, + capturedTypes: List, + isStatic: Boolean, +): DynamicCallSiteDesc { + val effectiveCapturedTypes = when { + isStatic -> capturedTypes + else -> listOf(targetType) + capturedTypes + } + + fun Class<*>.toClassDesc(): ClassDesc = + describeConstable().orElseThrow { IllegalArgumentException("$name cannot be transformed to a ClassDesc") } + + val interfaceJavaMethod = interfaceMethod.javaMethod!! + val targetInterface = interfaceJavaMethod.declaringClass.toClassDesc() + val methodReturnType = interfaceJavaMethod.returnType.toClassDesc() + val methodArguments = interfaceJavaMethod.parameterTypes.map { it.toClassDesc() } + + return DynamicCallSiteDesc.of( + lambdaMetafactoryDesc, + // The following parameters are from [[LambdaMetafactory#metafactory]] + // This is the 2nd argument of LambdaMetafactory#metafactory, "interfaceMethodName", + // the method name in Runnable is "run" + interfaceMethod.name, + // This is the 3rd argument of LambdaMetafactory#metafactory, "factoryType", + // the return type is the implemented interface, + // while the parameters are the captured variables + MethodTypeDesc.of(targetInterface, effectiveCapturedTypes), + // Bootstrap arguments (see `javap -c -v ` from a working .java sample) + // This is the 4th argument of LambdaMetafactory#metafactory, "interfaceMethodType", + // which is the signature of the implemented method, in this case, void Runnable.run() + MethodTypeDesc.of(methodReturnType, methodArguments), + // This is the 5th argument of LambdaMetafactory#metafactory, "implementation", + // this is the method to be called when invoking the lambda, + // with the captured variables and parameters + MethodHandleDesc.ofMethod( + if (isStatic) DirectMethodHandleDesc.Kind.STATIC else DirectMethodHandleDesc.Kind.VIRTUAL, + targetType, + targetMethod, + MethodTypeDesc.of(targetMethodReturnType, capturedTypes + targetMethodArguments) + ), + // This is the 6th argument of LambdaMetafactory#metafactory, "dynamicMethodType", + // this is "the signature and return type to be enforced dynamically at invocation type" + // This is usually the same as "interfaceMethodType" + MethodTypeDesc.of(methodReturnType, methodArguments), + ) +} diff --git a/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/utils/TransformUtils.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/utils/TransformUtils.kt new file mode 100644 index 000000000..b94859d27 --- /dev/null +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/transformer/utils/TransformUtils.kt @@ -0,0 +1,42 @@ +package dev.freya02.botcommands.jda.keepalive.internal.transformer.utils + +import java.lang.classfile.ClassBuilder +import java.lang.classfile.ClassFile.ACC_SYNTHETIC +import java.lang.classfile.ClassModel +import java.lang.classfile.MethodModel +import java.lang.reflect.AccessFlag +import kotlin.jvm.optionals.getOrNull + +internal fun Int.withVisibility(visibility: AccessFlag?): Int { + var flags = this + flags = flags and (AccessFlag.PUBLIC.mask() or AccessFlag.PROTECTED.mask() or AccessFlag.PRIVATE.mask()).inv() + if (visibility != null) // null = package-private + flags = flags or visibility.mask() + return flags +} + +internal fun MethodModel.matches(name: String, signature: String): Boolean { + return methodName().equalsString(name) && methodType().equalsString(signature) +} + +internal fun ClassModel.findMethod(name: String, signature: String): MethodModel { + return this.methods().firstOrNull { it.matches(name, signature) } + ?: error("Could not find ${this.thisClass().name().stringValue()}#$name$signature") +} + +context(classBuilder: ClassBuilder) +internal fun MethodModel.transferCodeTo(targetMethodName: String, visibility: AccessFlag = AccessFlag.PRIVATE) { + classBuilder.withMethodBody( + classBuilder.constantPool().utf8Entry(targetMethodName), + methodType(), + visibility.mask() or ACC_SYNTHETIC // Synthetic so this doesn't require a mock + ) { codeBuilder -> + val codeModel = code().orElseThrow { IllegalArgumentException("Method ${this.toFullyQualifiedString()} does not have code") } + codeModel.forEach { codeBuilder.with(it) } + } +} + +internal fun MethodModel.toFullyQualifiedString(): String { + val className = parent().getOrNull()?.thisClass()?.asSymbol()?.displayName() ?: "" + return "$className#${methodName().stringValue()}${methodTypeSymbol().displayDescriptor()}" +} diff --git a/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/utils/JvmUtils.kt b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/utils/JvmUtils.kt new file mode 100644 index 000000000..622d5e3d0 --- /dev/null +++ b/BotCommands-jda-keepalive/src/main/kotlin/dev/freya02/botcommands/jda/keepalive/internal/utils/JvmUtils.kt @@ -0,0 +1,10 @@ +package dev.freya02.botcommands.jda.keepalive.internal.utils + +internal fun isJvmShuttingDown() = try { + Runtime.getRuntime().removeShutdownHook(NullShutdownHook) + false +} catch (_: IllegalStateException) { + true +} + +private object NullShutdownHook : Thread() diff --git a/BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive/AgentTest.kt b/BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive/AgentTest.kt new file mode 100644 index 000000000..aec9d1b61 --- /dev/null +++ b/BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive/AgentTest.kt @@ -0,0 +1,37 @@ +package dev.freya02.botcommands.jda.keepalive + +import dev.freya02.botcommands.jda.keepalive.internal.Agent +import dev.freya02.botcommands.jda.keepalive.internal.exceptions.AlreadyLoadedClassesException +import io.mockk.every +import io.mockk.mockkObject +import org.junit.jupiter.api.assertNotNull +import kotlin.test.Test + +class AgentTest { + + @Test + fun `Dynamically loaded agent throws when classes are already loaded`() { + // Do a normal run to load classes + Agent.transformers.keys.forEach { + Class.forName("${it.packageName()}.${it.displayName()}") + } + + // Capture AlreadyLoadedClassesException + var agentmainException: AlreadyLoadedClassesException? = null + mockkObject(Agent) + every { Agent.checkNoLoadedClassesAreToBeTransformed(any()) } answers { + try { + callOriginal() + } catch (e: AlreadyLoadedClassesException) { + // This is thrown on a separate thread, so we need to capture it this way + agentmainException = e + // Don't throw + } + } + + // Load agent + Agent.load() + + assertNotNull(agentmainException) + } +} diff --git a/BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive/transformer/BContextImplTransformerTest.kt b/BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive/transformer/BContextImplTransformerTest.kt new file mode 100644 index 000000000..ebb65e8f8 --- /dev/null +++ b/BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive/transformer/BContextImplTransformerTest.kt @@ -0,0 +1,15 @@ +package dev.freya02.botcommands.jda.keepalive.transformer + +import org.junit.jupiter.api.assertDoesNotThrow +import kotlin.test.Test + +class BContextImplTransformerTest { + + @Test + fun `BContextImpl is instrumented`() { + assertDoesNotThrow { + Class.forName("io.github.freya022.botcommands.internal.core.BContextImpl") + .getDeclaredMethod("doScheduleShutdownSignal", Function0::class.java) + } + } +} diff --git a/BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive/transformer/JDABuilderTransformerTest.kt b/BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive/transformer/JDABuilderTransformerTest.kt new file mode 100644 index 000000000..46b52b22a --- /dev/null +++ b/BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive/transformer/JDABuilderTransformerTest.kt @@ -0,0 +1,135 @@ +package dev.freya02.botcommands.jda.keepalive.transformer + +import dev.freya02.botcommands.jda.keepalive.internal.BufferingEventManager +import dev.freya02.botcommands.jda.keepalive.internal.JDABuilderConfiguration +import dev.freya02.botcommands.jda.keepalive.internal.JDABuilderSession +import io.mockk.* +import net.dv8tion.jda.api.JDA +import net.dv8tion.jda.api.JDABuilder +import net.dv8tion.jda.api.OnlineStatus +import net.dv8tion.jda.api.events.GenericEvent +import net.dv8tion.jda.api.hooks.IEventManager +import net.dv8tion.jda.api.requests.GatewayIntent +import okhttp3.OkHttpClient +import org.junit.jupiter.api.assertThrows +import java.util.function.Supplier +import kotlin.test.Test + +class JDABuilderTransformerTest { + + @Test + fun `Constructor is instrumented`() { + val builderConfiguration = mockk(relaxUnitFun = true) + + mockkObject(JDABuilderSession) + every { JDABuilderSession.currentSession().configuration } answers { builderConfiguration } + + JDABuilder.create("MY_TOKEN", setOf(GatewayIntent.GUILD_MEMBERS)) + + verify(exactly = 1) { builderConfiguration.onInit("MY_TOKEN", GatewayIntent.getRaw(GatewayIntent.GUILD_MEMBERS)) } + } + + @Test + fun `Unsupported instance method invalidates cache`() { + // Initial set up, this *may* call "markIncompatible" so we need to do it before really mocking + val builder = createJDABuilder() + + // Actual test + val builderConfiguration = mockk { + every { onInit(any(), any()) } just runs + every { markUnsupportedValue(any()) } just runs + } + + mockkObject(JDABuilderSession) + every { JDABuilderSession.currentSession().configuration } returns builderConfiguration + + builder.setHttpClientBuilder(OkHttpClient.Builder()) + + verify(exactly = 1) { builderConfiguration.markUnsupportedValue(any()) } + } + + @Test + fun `Instance method is instrumented`() { + // Initial set up, this *may* call "markIncompatible" so we need to do it before really mocking + val builder = createJDABuilder() + + // Actual test + val builderConfiguration = mockk { + every { onInit(any(), any()) } just runs + every { setStatus(any()) } just runs + } + + mockkObject(JDABuilderSession) + every { JDABuilderSession.currentSession().configuration } returns builderConfiguration + + builder.setStatus(OnlineStatus.DO_NOT_DISTURB) + + verify(exactly = 1) { builderConfiguration.setStatus(OnlineStatus.DO_NOT_DISTURB) } + } + + @Test + fun `Build method is instrumented`() { + val builderSession = mockk { + every { onBuild(any()) } returns mockk() + every { configuration } returns mockk(relaxUnitFun = true) + } + + mockkObject(JDABuilderSession) + every { JDABuilderSession.currentSession() } returns builderSession + + JDABuilder.createDefault("MY_TOKEN").build() + + verify(exactly = 1) { builderSession.onBuild(any()) } + } + + @Test + fun `Build sets our event manager`() { + val builderConfiguration = mockk(relaxUnitFun = true) + + val builderSession = mockk { + every { onBuild(any()) } answers { arg>(0).get() } + every { configuration } returns builderConfiguration + } + + mockkObject(JDABuilderSession) + every { JDABuilderSession.currentSession() } returns builderSession + + val builder = spyk(JDABuilder.createDefault("MY_TOKEN").setEventManager(DummyEventManager)) + + // The special event manager is set on JDABuilder#build() before any original code is run + // so we'll throw an exception on the first method call of the original code, + // which is checkIntents() + every { builder["checkIntents"]() } throws ExpectedException() + assertThrows { builder.build() } + + verify(exactly = 1) { builder.setEventManager(ofType()) } + } + + /** + * Creates a basic JDABuilder, + * call this on the first line to not record any mocking data before doing the actual test. + */ + private fun createJDABuilder(): JDABuilder { + lateinit var builder: JDABuilder + mockkObject(JDABuilderSession) { + every { JDABuilderSession.currentSession().configuration } returns mockk(relaxUnitFun = true) + + builder = JDABuilder.create("MY_TOKEN", emptySet()) + } + + return builder + } + + private object DummyEventManager : IEventManager { + + override fun register(listener: Any) {} + + override fun unregister(listener: Any) {} + + override fun handle(event: GenericEvent) {} + + override fun getRegisteredListeners(): List = emptyList() + } + + private class ExpectedException : RuntimeException() +} diff --git a/BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive/transformer/JDAImplTransformerTest.kt b/BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive/transformer/JDAImplTransformerTest.kt new file mode 100644 index 000000000..bd8bf4116 --- /dev/null +++ b/BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive/transformer/JDAImplTransformerTest.kt @@ -0,0 +1,26 @@ +package dev.freya02.botcommands.jda.keepalive.transformer + +import dev.freya02.botcommands.jda.keepalive.internal.JDABuilderSession +import io.mockk.* +import net.dv8tion.jda.internal.JDAImpl +import kotlin.test.Test + +class JDAImplTransformerTest { + + @Test + fun `Shutdown method is instrumented`() { + val builderSession = mockk { + every { onShutdown(any(), any()) } just runs + } + + val jda = mockk { + // If this getter is missing, then the codegen changed + every { this@mockk["getBuilderSession"]() } returns builderSession + every { shutdown() } answers { callOriginal() } + } + + jda.shutdown() + + verify(exactly = 1) { builderSession.onShutdown(jda, any()) } + } +} diff --git a/BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive/transformer/JDAServiceTransformerTest.kt b/BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive/transformer/JDAServiceTransformerTest.kt new file mode 100644 index 000000000..2b04768db --- /dev/null +++ b/BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive/transformer/JDAServiceTransformerTest.kt @@ -0,0 +1,87 @@ +package dev.freya02.botcommands.jda.keepalive.transformer + +import dev.freya02.botcommands.jda.keepalive.internal.JDABuilderSession +import io.github.freya022.botcommands.api.core.JDAService +import io.github.freya022.botcommands.api.core.events.BReadyEvent +import io.mockk.* +import net.dv8tion.jda.api.hooks.IEventManager +import net.dv8tion.jda.api.requests.GatewayIntent +import net.dv8tion.jda.api.utils.cache.CacheFlag +import kotlin.test.Test + +class JDAServiceTransformerTest { + + class Bot : JDAService() { + + override val intents: Set = emptySet() + override val cacheFlags: Set = emptySet() + + public override fun createJDA(event: BReadyEvent, eventManager: IEventManager) { + println("createJDA") + } + } + + @Test + fun `Event listener is instrumented`() { + mockkObject(JDABuilderSession) + every { JDABuilderSession.withBuilderSession(any(), any()) } answers { callOriginal() } // Will call createJDA + + val onReadyEvent = JDAService::class.java.getDeclaredMethod($$"onReadyEvent$BotCommands", BReadyEvent::class.java, IEventManager::class.java) + val bot = mockk { + every { createJDA(any(), any()) } just runs + every { onReadyEvent.invoke(this@mockk, any(), any()) } answers { callOriginal() } // Will call withBuilderSession + } + + val readyEvent = mockk { + every { context.config.restartConfig.cacheKey } returns "Test" + } + val eventManager = mockk() + + onReadyEvent.invoke(bot, readyEvent, eventManager) + + verify(exactly = 1) { bot.createJDA(readyEvent, eventManager) } + } + + @Test + fun `Cache key enables builder sessions`() { + mockkObject(JDABuilderSession) + every { JDABuilderSession.withBuilderSession(any(), any()) } answers { callOriginal() } + + val onReadyEvent = JDAService::class.java.getDeclaredMethod($$"onReadyEvent$BotCommands", BReadyEvent::class.java, IEventManager::class.java) + val bot = mockk { + every { createJDA(any(), any()) } just runs + every { onReadyEvent.invoke(this@mockk, any(), any()) } answers { callOriginal() } // Will call withBuilderSession + } + + val readyEvent = mockk { + every { context.config.restartConfig.cacheKey } returns "Test" + } + val eventManager = mockk() + + onReadyEvent.invoke(bot, readyEvent, eventManager) + + verify(exactly = 1) { JDABuilderSession.withBuilderSession(any(), any()) } + } + + @Test + fun `Null cache key disables builder sessions`() { + mockkObject(JDABuilderSession) + every { JDABuilderSession.withBuilderSession(any(), any()) } answers { callOriginal() } + every { JDABuilderSession.getCacheKey(any()) } answers { callOriginal() } + + val onReadyEvent = JDAService::class.java.getDeclaredMethod($$"onReadyEvent$BotCommands", BReadyEvent::class.java, IEventManager::class.java) + val bot = mockk { + every { createJDA(any(), any()) } just runs + every { onReadyEvent.invoke(this@mockk, any(), any()) } answers { callOriginal() } // Will call withBuilderSession + } + + val readyEvent = mockk { + every { context.config.restartConfig.cacheKey } returns null + } + val eventManager = mockk() + + onReadyEvent.invoke(bot, readyEvent, eventManager) + + verify(exactly = 0) { JDABuilderSession.withBuilderSession(any(), any()) } + } +} diff --git a/BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive/transformer/utils/CodeBuilderUtilsTest.kt b/BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive/transformer/utils/CodeBuilderUtilsTest.kt new file mode 100644 index 000000000..24170a839 --- /dev/null +++ b/BotCommands-jda-keepalive/src/test/kotlin/dev/freya02/botcommands/jda/keepalive/transformer/utils/CodeBuilderUtilsTest.kt @@ -0,0 +1,123 @@ +package dev.freya02.botcommands.jda.keepalive.transformer.utils + +import dev.freya02.botcommands.jda.keepalive.internal.transformer.* +import dev.freya02.botcommands.jda.keepalive.internal.transformer.utils.createLambda +import dev.freya02.botcommands.jda.keepalive.internal.transformer.utils.lambdaMetafactoryDesc +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import java.lang.constant.* +import java.util.function.Supplier +import kotlin.test.assertEquals + +object CodeBuilderUtilsTest { + + @MethodSource("Test createLambda") + @ParameterizedTest + fun `Test createLambda`(expected: DynamicCallSiteDesc, actual: DynamicCallSiteDesc) { + assertEquals(expected, actual) + } + + @JvmStatic + fun `Test createLambda`(): List = listOf( + Arguments.of( + DynamicCallSiteDesc.of( + lambdaMetafactoryDesc, + "get", + MethodTypeDesc.of(CD_Supplier, CD_JDABuilder), + MethodTypeDesc.of(ConstantDescs.CD_Object), + MethodHandleDesc.ofMethod( + DirectMethodHandleDesc.Kind.VIRTUAL, + CD_JDABuilder, + "doBuild", + MethodTypeDesc.of(CD_JDA) + ), + MethodTypeDesc.of(ConstantDescs.CD_Object), + ), + createLambda( + interfaceMethod = Supplier<*>::get, + targetType = CD_JDABuilder, + targetMethod = "doBuild", + targetMethodReturnType = CD_JDA, + targetMethodArguments = listOf(), + capturedTypes = listOf(), + isStatic = false + ) + ), + + Arguments.of( + DynamicCallSiteDesc.of( + lambdaMetafactoryDesc, + "run", + MethodTypeDesc.of(CD_Runnable, CD_JDAService, CD_BReadyEvent, CD_IEventManager), + MethodTypeDesc.of(ConstantDescs.CD_void), + MethodHandleDesc.ofMethod( + DirectMethodHandleDesc.Kind.VIRTUAL, + CD_JDAService, + $$"lambda$onReadyEvent$BotCommands$withBuilderSession", + MethodTypeDesc.of(ConstantDescs.CD_void, CD_BReadyEvent, CD_IEventManager) + ), + MethodTypeDesc.of(ConstantDescs.CD_void), + ), + createLambda( + interfaceMethod = Runnable::run, + targetType = CD_JDAService, + targetMethod = $$"lambda$onReadyEvent$BotCommands$withBuilderSession", + targetMethodReturnType = ConstantDescs.CD_void, + targetMethodArguments = listOf(), + capturedTypes = listOf(CD_BReadyEvent, CD_IEventManager), + isStatic = false + ) + ), + + Arguments.of( + DynamicCallSiteDesc.of( + lambdaMetafactoryDesc, + "run", + MethodTypeDesc.of(CD_Runnable, CD_JDAImpl), + MethodTypeDesc.of(ConstantDescs.CD_void), + MethodHandleDesc.ofMethod( + DirectMethodHandleDesc.Kind.VIRTUAL, + CD_JDAImpl, + "doShutdown", + MethodTypeDesc.of(ConstantDescs.CD_void) + ), + MethodTypeDesc.of(ConstantDescs.CD_void), + ), + createLambda( + interfaceMethod = Runnable::run, + targetType = CD_JDAImpl, + targetMethod = "doShutdown", + targetMethodReturnType = ConstantDescs.CD_void, + targetMethodArguments = listOf(), + capturedTypes = listOf(), + isStatic = false + ) + ), + + Arguments.of( + DynamicCallSiteDesc.of( + lambdaMetafactoryDesc, + "run", + MethodTypeDesc.of(CD_Runnable, CD_BContextImpl, CD_Function0), + MethodTypeDesc.of(ConstantDescs.CD_void), + MethodHandleDesc.ofMethod( + DirectMethodHandleDesc.Kind.VIRTUAL, + CD_BContextImpl, + "doScheduleShutdownSignal", + MethodTypeDesc.of(ConstantDescs.CD_void, CD_Function0) + ), + MethodTypeDesc.of(ConstantDescs.CD_void), + ), + createLambda( + interfaceMethod = Runnable::run, + targetType = CD_BContextImpl, + targetMethod = "doScheduleShutdownSignal", + targetMethodReturnType = ConstantDescs.CD_void, + targetMethodArguments = listOf(), + capturedTypes = listOf(CD_Function0), + isStatic = false + ) + ), + ) +} diff --git a/BotCommands-jda-keepalive/src/test/resources/logback-test.xml b/BotCommands-jda-keepalive/src/test/resources/logback-test.xml new file mode 100644 index 000000000..97d41eb06 --- /dev/null +++ b/BotCommands-jda-keepalive/src/test/resources/logback-test.xml @@ -0,0 +1,14 @@ + + + + + %d{HH:mm:ss.SSS} %boldCyan(%-26.-26thread) %boldYellow(%-20.-20logger{0}) %highlight(%-6level) %msg%n%throwable + + + + + + + + + diff --git a/BotCommands-jda-keepalive/src/testBot/kotlin/dev/freya02/botcommands/jda/keepalive/Bot.kt b/BotCommands-jda-keepalive/src/testBot/kotlin/dev/freya02/botcommands/jda/keepalive/Bot.kt new file mode 100644 index 000000000..bbbc42084 --- /dev/null +++ b/BotCommands-jda-keepalive/src/testBot/kotlin/dev/freya02/botcommands/jda/keepalive/Bot.kt @@ -0,0 +1,27 @@ +package dev.freya02.botcommands.jda.keepalive + +import io.github.freya022.botcommands.api.core.JDAService +import io.github.freya022.botcommands.api.core.events.BReadyEvent +import io.github.freya022.botcommands.api.core.light +import io.github.freya022.botcommands.api.core.service.annotations.BService +import net.dv8tion.jda.api.hooks.IEventManager +import net.dv8tion.jda.api.requests.GatewayIntent +import net.dv8tion.jda.api.utils.cache.CacheFlag + +@BService +class Bot( + private val config: Config, +) : JDAService() { + + override val intents: Set = emptySet() + override val cacheFlags: Set = emptySet() + + override fun createJDA(event: BReadyEvent, eventManager: IEventManager) { + light( + token = config.token, + restConfig = null, + ) { + + } + } +} diff --git a/BotCommands-jda-keepalive/src/testBot/kotlin/dev/freya02/botcommands/jda/keepalive/Config.kt b/BotCommands-jda-keepalive/src/testBot/kotlin/dev/freya02/botcommands/jda/keepalive/Config.kt new file mode 100644 index 000000000..6b80bd3b3 --- /dev/null +++ b/BotCommands-jda-keepalive/src/testBot/kotlin/dev/freya02/botcommands/jda/keepalive/Config.kt @@ -0,0 +1,24 @@ +package dev.freya02.botcommands.jda.keepalive + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.module.kotlin.readValue +import io.github.freya022.botcommands.api.core.service.annotations.BService +import io.github.freya022.botcommands.api.core.utils.DefaultObjectMapper +import kotlin.io.path.Path + +data class Config( + val token: String, +) { + + companion object { + val configDirectory = Path("test-files", "test", "dev-config") + private val configFile = configDirectory.resolve("config.json") + + @get:BService + val instance: Config by lazy { + DefaultObjectMapper.mapper + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .readValue(configFile.toFile()) + } + } +} diff --git a/BotCommands-jda-keepalive/src/testBot/kotlin/dev/freya02/botcommands/jda/keepalive/Main.kt b/BotCommands-jda-keepalive/src/testBot/kotlin/dev/freya02/botcommands/jda/keepalive/Main.kt new file mode 100644 index 000000000..9989c84e2 --- /dev/null +++ b/BotCommands-jda-keepalive/src/testBot/kotlin/dev/freya02/botcommands/jda/keepalive/Main.kt @@ -0,0 +1,24 @@ +package dev.freya02.botcommands.jda.keepalive + +import ch.qos.logback.classic.ClassicConstants +import dev.freya02.botcommands.jda.keepalive.api.JDAKeepAlive +import io.github.freya022.botcommands.api.core.BotCommands +import io.github.freya022.botcommands.api.restart.ExperimentalRestartApi +import kotlin.io.path.absolutePathString + +fun main() { + System.setProperty(ClassicConstants.CONFIG_FILE_PROPERTY, Config.configDirectory.resolve("logback-test.xml").absolutePathString()) + + // For those reading this, specifying the agent on the command line is better + // but build tool/IDE support sucks, so dynamic loading it is + JDAKeepAlive.install() + + BotCommands.create(emptyArray()) { + addSearchPath("dev.freya02.botcommands.jda.keepalive") + + @OptIn(ExperimentalRestartApi::class) + restart { + cacheKey = "random text unique per instance" + } + } +} diff --git a/BotCommands-jda-keepalive/src/testBot/kotlin/dev/freya02/botcommands/jda/keepalive/SlashTest.kt b/BotCommands-jda-keepalive/src/testBot/kotlin/dev/freya02/botcommands/jda/keepalive/SlashTest.kt new file mode 100644 index 000000000..de835aba6 --- /dev/null +++ b/BotCommands-jda-keepalive/src/testBot/kotlin/dev/freya02/botcommands/jda/keepalive/SlashTest.kt @@ -0,0 +1,16 @@ +package dev.freya02.botcommands.jda.keepalive + +import dev.minn.jda.ktx.messages.reply_ +import io.github.freya022.botcommands.api.commands.annotations.Command +import io.github.freya022.botcommands.api.commands.application.ApplicationCommand +import io.github.freya022.botcommands.api.commands.application.slash.GuildSlashEvent +import io.github.freya022.botcommands.api.commands.application.slash.annotations.JDASlashCommand + +@Command +class SlashTest : ApplicationCommand() { + + @JDASlashCommand(name = "test", description = "No description") + fun onSlashTest(event: GuildSlashEvent) { + event.reply_("foo", ephemeral = true).queue() + } +} diff --git a/BotCommands-restarter/build.gradle.kts b/BotCommands-restarter/build.gradle.kts new file mode 100644 index 000000000..ff9e401da --- /dev/null +++ b/BotCommands-restarter/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + id("BotCommands-conventions") + id("BotCommands-publish-conventions") +} + +dependencies { + api(projects.botCommands) + + // Logging + implementation(libs.kotlin.logging) +} diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/ImmediateRestartException.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/ImmediateRestartException.kt new file mode 100644 index 000000000..19edb5b80 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/ImmediateRestartException.kt @@ -0,0 +1,29 @@ +package dev.freya02.botcommands.internal.restart + +import java.lang.reflect.InvocationTargetException + +class ImmediateRestartException internal constructor() : RuntimeException("Dummy exception to stop the execution of the first main thread") { + + internal companion object { + internal fun throwAndHandle(): Nothing { + val currentThread = Thread.currentThread() + currentThread.uncaughtExceptionHandler = ExpectedReloadExceptionHandler(currentThread.uncaughtExceptionHandler) + throw ImmediateRestartException() + } + } + + private class ExpectedReloadExceptionHandler(private val delegate: Thread.UncaughtExceptionHandler?) : Thread.UncaughtExceptionHandler { + + override fun uncaughtException(t: Thread, e: Throwable) { + if (e is ImmediateRestartException || (e is InvocationTargetException && e.targetException is ImmediateRestartException)) { + return + } + + if (delegate != null) { + delegate.uncaughtException(t, e) + } else { + e.printStackTrace() + } + } + } +} \ No newline at end of file diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/LeakSafeExecutor.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/LeakSafeExecutor.kt new file mode 100644 index 000000000..b36bbbed6 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/LeakSafeExecutor.kt @@ -0,0 +1,61 @@ +package dev.freya02.botcommands.internal.restart + +import java.util.concurrent.BlockingDeque +import java.util.concurrent.LinkedBlockingDeque +import kotlin.system.exitProcess + +internal class LeakSafeExecutor internal constructor() { + + // As we can only use a Thread once, we put a single LeakSafeThread in a blocking queue, + // then, when a code block runs, a LeakSafeThread is removed from the queue, + // and the LeakSafeThread recreates a new one for the next code block. + // We use a blocking queue to prevent trying to get a LeakSafeThread between the moment it was retrieved and when it'll be added back + private val leakSafeThreads: BlockingDeque = LinkedBlockingDeque() + + init { + leakSafeThreads += LeakSafeThread() + } + + fun callAndWait(callable: () -> V): V = getLeakSafeThread().callAndWait(callable) + + private fun getLeakSafeThread(): LeakSafeThread { + return leakSafeThreads.takeFirst() + } + + /** + * Thread that is created early so not to retain the [RestartClassLoader]. + */ + private inner class LeakSafeThread : Thread() { + + private var callable: (() -> Any?)? = null + + private var result: Any? = null + + init { + isDaemon = false + } + + @Suppress("UNCHECKED_CAST") + fun callAndWait(callable: () -> V): V { + this.callable = callable + start() + try { + join() + return this.result as V + } catch (ex: InterruptedException) { + currentThread().interrupt() + throw IllegalStateException(ex) + } + } + + override fun run() { + try { + this@LeakSafeExecutor.leakSafeThreads.put(LeakSafeThread()) + this.result = this.callable!!.invoke() + } catch (ex: Exception) { + ex.printStackTrace() + exitProcess(1) + } + } + } +} \ No newline at end of file diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/RestartClassLoader.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/RestartClassLoader.kt new file mode 100644 index 000000000..d803b7800 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/RestartClassLoader.kt @@ -0,0 +1,40 @@ +package dev.freya02.botcommands.internal.restart + +import java.net.URL +import java.net.URLClassLoader +import java.util.* + +// STILL SUPER DUPER IMPORTANT TO OVERRIDE SOME STUFF AND DELEGATE +internal class RestartClassLoader internal constructor( + urls: List, + parent: ClassLoader, +) : URLClassLoader(urls.toTypedArray(), parent) { + + override fun getResources(name: String): Enumeration { + return this.parent.getResources(name) + } + + override fun getResource(name: String): URL? { + return findResource(name) ?: super.getResource(name) + } + + override fun findResource(name: String): URL? { + return super.findResource(name) + } + + override fun loadClass(name: String, resolve: Boolean): Class<*> { + return synchronized(getClassLoadingLock(name)) { + val loadedClass = findLoadedClass(name) ?: try { + findClass(name) + } catch (_: ClassNotFoundException) { + Class.forName(name, false, parent) + } + if (resolve) resolveClass(loadedClass) + loadedClass + } + } + + override fun findClass(name: String): Class<*> { + return super.findClass(name) + } +} \ No newline at end of file diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/RestartClassLoaderFull.kt.disabled b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/RestartClassLoaderFull.kt.disabled new file mode 100644 index 000000000..605a0b4ee --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/RestartClassLoaderFull.kt.disabled @@ -0,0 +1,116 @@ +package dev.freya02.botcommands.internal.restart + +import dev.freya02.botcommands.internal.restart.sources.DeletedSourceFile +import dev.freya02.botcommands.internal.restart.sources.SourceDirectories +import dev.freya02.botcommands.internal.restart.sources.SourceFile +import java.io.InputStream +import java.net.URL +import java.net.URLClassLoader +import java.net.URLConnection +import java.net.URLStreamHandler +import java.util.* + +internal class RestartClassLoader internal constructor( + urls: List, + parent: ClassLoader, + private val sourceDirectories: SourceDirectories, +) : URLClassLoader(urls.toTypedArray(), parent) { + + override fun getResources(name: String): Enumeration { + val resources = parent.getResources(name) + val updatedFile = sourceDirectories.getFile(name) + + if (updatedFile != null) { + if (resources.hasMoreElements()) { + resources.nextElement() + } + if (updatedFile is SourceFile) { + return MergedEnumeration(createFileUrl(name, updatedFile), resources) + } + } + + return resources + } + + override fun getResource(name: String): URL? { + val updatedFile = sourceDirectories.getFile(name) + if (updatedFile is DeletedSourceFile) { + return null + } + + return findResource(name) ?: super.getResource(name) + } + + override fun findResource(name: String): URL? { + val updatedFile = sourceDirectories.getFile(name) + ?: return super.findResource(name) + return (updatedFile as? SourceFile)?.let { createFileUrl(name, it) } + } + + override fun loadClass(name: String, resolve: Boolean): Class<*> { + val path = "${name.replace('.', '/')}.class" + val updatedFile = sourceDirectories.getFile(path) + if (updatedFile is DeletedSourceFile) + throw ClassNotFoundException(name) + + return synchronized(getClassLoadingLock(name)) { + val loadedClass = findLoadedClass(name) ?: try { + findClass(name) + } catch (_: ClassNotFoundException) { + Class.forName(name, false, parent) + } + if (resolve) resolveClass(loadedClass) + loadedClass + } + } + + override fun findClass(name: String): Class<*> { + val path = "${name.replace('.', '/')}.class" + val updatedFile = sourceDirectories.getFile(path) + ?: return super.findClass(name) + if (updatedFile is DeletedSourceFile) + throw ClassNotFoundException(name) + + updatedFile as SourceFile + return defineClass(name, updatedFile.bytes, 0, updatedFile.bytes.size) + } + + @Suppress("DEPRECATION") // We target Java 17 but JDK 20 deprecates the URL constructors + private fun createFileUrl(name: String, file: SourceFile): URL { + return URL("reloaded", null, -1, "/$name", ClasspathFileURLStreamHandler(file)) + } + + private class ClasspathFileURLStreamHandler( + private val file: SourceFile, + ) : URLStreamHandler() { + + override fun openConnection(u: URL): URLConnection = Connection(u) + + private inner class Connection(url: URL): URLConnection(url) { + + override fun connect() {} + + override fun getInputStream(): InputStream = file.bytes.inputStream() + + override fun getLastModified(): Long = file.lastModified.toEpochMilli() + + override fun getContentLengthLong(): Long = file.bytes.size.toLong() + } + } + + private class MergedEnumeration(private val first: E, private val rest: Enumeration) : Enumeration { + + private var hasConsumedFirst = false + + override fun hasMoreElements(): Boolean = !hasConsumedFirst || rest.hasMoreElements() + + override fun nextElement(): E? { + if (!hasConsumedFirst) { + hasConsumedFirst = true + return first + } else { + return rest.nextElement() + } + } + } +} \ No newline at end of file diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/RestartListener.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/RestartListener.kt new file mode 100644 index 000000000..4d773905c --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/RestartListener.kt @@ -0,0 +1,5 @@ +package dev.freya02.botcommands.internal.restart + +interface RestartListener { + fun beforeStop() +} \ No newline at end of file diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/Restarter.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/Restarter.kt new file mode 100644 index 000000000..e60d6db87 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/Restarter.kt @@ -0,0 +1,125 @@ +package dev.freya02.botcommands.internal.restart + +import dev.freya02.botcommands.internal.restart.utils.AppClasspath +import io.github.oshai.kotlinlogging.KotlinLogging +import java.net.URL +import java.util.concurrent.locks.Lock +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.thread +import kotlin.concurrent.withLock + +private val logger = KotlinLogging.logger { } + +class Restarter private constructor( + private val args: List, +) { + + private val appClassLoader: ClassLoader + val appClasspathUrls: List + + private val mainClassName: String + + private val uncaughtExceptionHandler: Thread.UncaughtExceptionHandler + + private val stopLock: Lock = ReentrantLock() + private val listeners: MutableList = arrayListOf() + + private val leakSafeExecutor = LeakSafeExecutor() + + init { + val thread = Thread.currentThread() + + appClassLoader = thread.contextClassLoader + appClasspathUrls = AppClasspath.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 + } + + fun addListener(listener: RestartListener) { + listeners += listener + } + + private fun initialize(): Nothing { + val throwable = leakSafeExecutor.callAndWait { start() } + if (throwable != null) + throw throwable + ImmediateRestartException.throwAndHandle() + } + + /** + * Runs each [RestartListener.beforeStop] and then starts a new instance of the main class, + * if the new instance fails, the [Throwable] is returned. + */ + fun restart(): Throwable? { + logger.debug { "Restarting application in '$mainClassName'" } + // Do it from the original class loader, so the context is the same as for the initial restart + return leakSafeExecutor.callAndWait { + stop() + start() + } + } + + private fun stop() { + stopLock.withLock { + listeners.forEach { it.beforeStop() } + listeners.clear() + } + // All threads should be stopped at that point + // so the GC should be able to remove all the previous loaded classes + System.gc() + } + + /** + * Starts a new instance of the main class, or returns a [Throwable] if it failed. + */ + private fun start(): Throwable? { + // We use a regular URLClassLoader instead of RestartClassLoaderFull, + // as classpath changes will trigger a restart and thus recreate a new ClassLoader, + // meaning live updating the classes is pointless. + // In contrast, Spring needs their RestartClassLoader because it can override classes remotely, + // but we don't have such a use case. + // However, not using RestartClassLoaderFull, which uses snapshots, has an issue, + // trying to load deleted classes (most likely on shutdown) will fail, + // Spring also has that issue, but it will only happen on classes out of its component scan, + // BC just needs to make sure to at least load the classes on its path too. + val restartClassLoader = RestartClassLoader(appClasspathUrls, appClassLoader) + var error: Throwable? = null + val launchThreads = thread(name = RESTARTED_THREAD_NAME, isDaemon = false, contextClassLoader = restartClassLoader) { + try { + val mainClass = Class.forName(mainClassName, false, restartClassLoader) + val mainMethod = mainClass.getDeclaredMethod("main", Array::class.java) + mainMethod.isAccessible = true + mainMethod.invoke(null, args.toTypedArray()) + } catch (ex: Throwable) { + error = ex + } + } + launchThreads.join() + + return error + } + + companion object { + + const val RESTARTED_THREAD_NAME = "restartedMain" + + private val instanceLock: Lock = ReentrantLock() + lateinit var instance: Restarter + private set + + fun initialize(args: List) { + var newInstance: Restarter? = null + instanceLock.withLock { + if (::instance.isInitialized.not()) { + newInstance = Restarter(args) + instance = newInstance + } + } + newInstance?.initialize() + } + } +} diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/services/RestarterApplicationStartListener.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/services/RestarterApplicationStartListener.kt new file mode 100644 index 000000000..63886c377 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/services/RestarterApplicationStartListener.kt @@ -0,0 +1,16 @@ +package dev.freya02.botcommands.internal.restart.services + +import dev.freya02.botcommands.internal.restart.Restarter +import io.github.freya022.botcommands.api.core.events.ApplicationStartListener +import io.github.freya022.botcommands.api.core.events.BApplicationStartEvent +import io.github.freya022.botcommands.api.core.service.annotations.BService +import io.github.freya022.botcommands.api.core.service.annotations.RequiresDefaultInjection + +@BService +@RequiresDefaultInjection +internal class RestarterApplicationStartListener : ApplicationStartListener { + + override fun onApplicationStart(event: BApplicationStartEvent) { + Restarter.initialize(event.args) + } +} \ No newline at end of file diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/services/RestarterService.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/services/RestarterService.kt new file mode 100644 index 000000000..5fe838875 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/services/RestarterService.kt @@ -0,0 +1,26 @@ +package dev.freya02.botcommands.internal.restart.services + +import dev.freya02.botcommands.internal.restart.RestartListener +import dev.freya02.botcommands.internal.restart.Restarter +import dev.freya02.botcommands.internal.restart.watcher.ClasspathWatcher +import io.github.freya022.botcommands.api.core.annotations.BEventListener +import io.github.freya022.botcommands.api.core.events.PostLoadEvent +import io.github.freya022.botcommands.api.core.service.annotations.BService +import io.github.freya022.botcommands.api.core.service.annotations.RequiresDefaultInjection + +@BService +@RequiresDefaultInjection +internal class RestarterService { + + @BEventListener + fun onPostLoad(event: PostLoadEvent) { + val context = event.context + val config = context.restartConfig + Restarter.instance.addListener(object : RestartListener { + override fun beforeStop() { + context.shutdownNow() + } + }) + ClasspathWatcher.initialize(config.restartDelay) + } +} diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceDirectories.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceDirectories.kt new file mode 100644 index 000000000..2dc84c9cf --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceDirectories.kt @@ -0,0 +1,48 @@ +package dev.freya02.botcommands.internal.restart.sources + +import java.nio.file.Path + +internal class SourceDirectories internal constructor() { + private val directories: MutableMap = hashMapOf() + + internal fun getFile(path: String): ISourceFile? { + return directories.firstNotNullOfOrNull { it.value.files[path] } + } + + internal fun setSource(source: SourceDirectory) { + directories[source.directory] = source + } + + internal fun replaceSource(key: Path, directory: SourceDirectory) { + check(key in directories) + + directories[key] = directory + } + + internal fun close() { + directories.values.forEach { it.close() } + } +} + +internal fun SourceDirectories(directories: List, listener: SourceDirectoriesListener): SourceDirectories { + val sourceDirectories = SourceDirectories() + + fun onSourceDirectoryUpdate(directory: Path, sourceFilesFactory: () -> SourceFiles) { + // The command is called when restarting + // so we don't make snapshots before all changes went through + listener.onChange(command = { + val newSourceDirectory = SourceDirectory( + directory, + sourceFilesFactory(), + listener = { onSourceDirectoryUpdate(directory, it) } + ) + sourceDirectories.replaceSource(directory, newSourceDirectory) + }) + } + + directories.forEach { directory -> + sourceDirectories.setSource(SourceDirectory(directory, listener = { onSourceDirectoryUpdate(directory, it) })) + } + + return sourceDirectories +} \ No newline at end of file diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceDirectoriesListener.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceDirectoriesListener.kt new file mode 100644 index 000000000..75b9f721b --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceDirectoriesListener.kt @@ -0,0 +1,7 @@ +package dev.freya02.botcommands.internal.restart.sources + +internal interface SourceDirectoriesListener { + fun onChange(command: () -> Unit) + + fun onCancel() +} \ No newline at end of file diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceDirectory.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceDirectory.kt new file mode 100644 index 000000000..cc5ab38c7 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceDirectory.kt @@ -0,0 +1,90 @@ +package dev.freya02.botcommands.internal.restart.sources + +import dev.freya02.botcommands.internal.restart.utils.walkDirectories +import dev.freya02.botcommands.internal.restart.utils.walkFiles +import io.github.oshai.kotlinlogging.KotlinLogging +import java.nio.file.Path +import java.nio.file.StandardWatchEventKinds.* +import kotlin.concurrent.thread +import kotlin.io.path.* + +private val logger = KotlinLogging.logger { } + +@OptIn(ExperimentalPathApi::class) +internal class SourceDirectory internal constructor( + val directory: Path, + val files: SourceFiles, + private val listener: SourceDirectoryListener, +) { + + private val thread: Thread + + init { + require(directory.isDirectory()) + + logger.trace { "Listening to ${directory.absolutePathString()}" } + + val watchService = directory.fileSystem.newWatchService() + directory.walkDirectories { path, attributes -> + path.register(watchService, ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE) + } + + thread = thread(name = "Classpath watcher of '${directory.fileName}'", isDaemon = true) { + try { + watchService.take() // Wait for a change + } catch (_: InterruptedException) { + return@thread logger.trace { "Interrupted watching ${directory.absolutePathString()}" } + } + watchService.close() + + listener.onChange(sourcesFilesFactory = { + val snapshot = directory.takeSnapshot() + + // Exclude deleted files so they don't count as being deleted again + val deletedPaths = files.withoutDeletes().keys - snapshot.keys + if (deletedPaths.isNotEmpty()) { + logger.info { "Deleted files in ${directory.absolutePathString()}: $deletedPaths" } + return@onChange deletedPaths.associateWith { DeletedSourceFile } + snapshot + } + + // Exclude deleted files so they count as being added back + val addedPaths = snapshot.keys - files.withoutDeletes().keys + if (addedPaths.isNotEmpty()) { + logger.info { "Added files in ${directory.absolutePathString()}: $addedPaths" } + return@onChange files + snapshot + } + + val modifiedFiles = snapshot.keys.filter { key -> + val actual = snapshot[key] ?: error("Key from map is missing a value somehow") + val expected = files[key] ?: error("Expected file is missing, should have been detected as deleted") + + // File was deleted (on the 2nd build for example) and got recreated (on the 3rd build for example) + if (expected is DeletedSourceFile) error("Expected file was registered as deleted, should have been detected as added") + expected as SourceFile + + actual as SourceFile // Assertion + + actual.lastModified != expected.lastModified + } + if (modifiedFiles.isNotEmpty()) { + logger.info { "Timestamp changed in ${directory.absolutePathString()}: $modifiedFiles" } + return@onChange files + snapshot + } + + error("Received a file system event but no changes were detected") + }) + } + } + + internal fun close() { + thread.interrupt() + } +} + +internal fun SourceDirectory(directory: Path, listener: SourceDirectoryListener): SourceDirectory { + return SourceDirectory(directory, directory.takeSnapshot(), listener) +} + +private fun Path.takeSnapshot(): SourceFiles = walkFiles().associate { (it, attrs) -> + it.relativeTo(this).pathString to SourceFile(attrs.lastModifiedTime().toInstant()) +}.let(::SourceFiles) diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceDirectoryListener.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceDirectoryListener.kt new file mode 100644 index 000000000..535021105 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceDirectoryListener.kt @@ -0,0 +1,5 @@ +package dev.freya02.botcommands.internal.restart.sources + +internal fun interface SourceDirectoryListener { + fun onChange(sourcesFilesFactory: () -> SourceFiles) +} \ No newline at end of file diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceFile.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceFile.kt new file mode 100644 index 000000000..648bb579d --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceFile.kt @@ -0,0 +1,15 @@ +package dev.freya02.botcommands.internal.restart.sources + +import java.time.Instant + +internal sealed interface ISourceFile + +internal class SourceFile( + val lastModified: Instant, +) : ISourceFile { + + val bytes: ByteArray + get() = throw UnsupportedOperationException("Class data is no longer retained as RestartClassLoader is not used yet") +} + +internal object DeletedSourceFile : ISourceFile \ No newline at end of file diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceFiles.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceFiles.kt new file mode 100644 index 000000000..2ded12566 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/sources/SourceFiles.kt @@ -0,0 +1,16 @@ +package dev.freya02.botcommands.internal.restart.sources + +internal class SourceFiles internal constructor( + internal val files: Map, +) { + + val keys: Set get() = files.keys + + internal operator fun get(path: String): ISourceFile? = files[path] + + internal fun withoutDeletes(): SourceFiles = SourceFiles(files.filterValues { it !is DeletedSourceFile }) + + internal operator fun plus(other: SourceFiles): SourceFiles = SourceFiles(files + other.files) +} + +internal operator fun Map.plus(other: SourceFiles): SourceFiles = SourceFiles(this + other.files) \ No newline at end of file diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/utils/AppClasspath.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/utils/AppClasspath.kt new file mode 100644 index 000000000..1375d20f2 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/utils/AppClasspath.kt @@ -0,0 +1,60 @@ +package dev.freya02.botcommands.internal.restart.utils + +import io.github.oshai.kotlinlogging.KotlinLogging +import java.io.File +import java.lang.management.ManagementFactory +import java.nio.file.Path +import java.util.* +import kotlin.io.path.Path +import kotlin.io.path.isDirectory + +private val logger = KotlinLogging.logger { } + +internal object AppClasspath { + + val paths: List + + init { + val resources = Thread.currentThread().contextClassLoader.getResources("META-INF/BotCommands-restarter.properties") + + val excludePatterns = buildSet { + resources.iterator().forEach { url -> + val properties = url.openStream().use { inputStream -> + val prop = Properties() + prop.load(inputStream) + prop + } + + // Load "restart.exclude.[patternName]=[pattern]" + properties.forEach { key, value -> + if (key !is String) return@forEach + if (value !is String) return@forEach + + val patternName = key.substringAfter("restart.exclude.", missingDelimiterValue = "") + if (patternName.isNotBlank() && value.isNotBlank()) { + add(value.toRegex()) + } + } + } + } + + logger.debug { "Restart classpath exclude patterns: $excludePatterns" } + + val (includedPaths, excludedPaths) = ManagementFactory.getRuntimeMXBean().classPath + .split(File.pathSeparator) + .map(::Path) + .filter { it.isDirectory() } + .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/internal/restart/utils/NIO.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/utils/NIO.kt new file mode 100644 index 000000000..cbcbb7d88 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/utils/NIO.kt @@ -0,0 +1,66 @@ +package dev.freya02.botcommands.internal.restart.utils + +import java.io.IOException +import java.nio.file.FileVisitResult +import java.nio.file.FileVisitor +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.BasicFileAttributes + +// Optimization of Path#walk, cuts CPU usage by 4 +// mostly by eliminating duplicate calls to file attributes +internal fun Path.walkFiles(): List> { + return buildList { + Files.walkFileTree(this@walkFiles, object : FileVisitor { + override fun preVisitDirectory( + dir: Path, + attrs: BasicFileAttributes + ): FileVisitResult = FileVisitResult.CONTINUE + + override fun visitFile( + file: Path, + attrs: BasicFileAttributes + ): FileVisitResult { + add(file to attrs) + return FileVisitResult.CONTINUE + } + + override fun visitFileFailed( + file: Path, + exc: IOException + ): FileVisitResult = FileVisitResult.CONTINUE + + override fun postVisitDirectory( + dir: Path, + exc: IOException? + ): FileVisitResult = FileVisitResult.CONTINUE + }) + } +} + +internal fun Path.walkDirectories(block: (Path, BasicFileAttributes) -> Unit) { + Files.walkFileTree(this@walkDirectories, object : FileVisitor { + override fun preVisitDirectory( + dir: Path, + attrs: BasicFileAttributes + ): FileVisitResult { + block(dir, attrs) + return FileVisitResult.CONTINUE + } + + override fun visitFile( + file: Path, + attrs: BasicFileAttributes + ): FileVisitResult = FileVisitResult.CONTINUE + + override fun visitFileFailed( + file: Path, + exc: IOException + ): FileVisitResult = FileVisitResult.CONTINUE + + override fun postVisitDirectory( + dir: Path, + exc: IOException? + ): FileVisitResult = FileVisitResult.CONTINUE + }) +} \ No newline at end of file diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/watcher/ClasspathListener.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/watcher/ClasspathListener.kt new file mode 100644 index 000000000..6805bd4d8 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/watcher/ClasspathListener.kt @@ -0,0 +1,42 @@ +package dev.freya02.botcommands.internal.restart.watcher + +import dev.freya02.botcommands.internal.restart.Restarter +import dev.freya02.botcommands.internal.restart.sources.SourceDirectoriesListener +import io.github.oshai.kotlinlogging.KotlinLogging +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit +import kotlin.time.Duration + +private val logger = KotlinLogging.logger { } + +internal class ClasspathListener internal constructor( + private val delay: Duration +) : SourceDirectoriesListener { + + private val scheduler = Executors.newSingleThreadScheduledExecutor() + private lateinit var scheduledRestart: ScheduledFuture<*> + + private val commands: MutableList<() -> Unit> = arrayListOf() + + override fun onChange(command: () -> Unit) { + commands += command + if (::scheduledRestart.isInitialized) scheduledRestart.cancel(false) + + scheduledRestart = scheduler.schedule({ + commands.forEach { it.invoke() } + commands.clear() + + try { + Restarter.instance.restart() + } catch (e: Exception) { + logger.error(e) { "Restart failed, waiting for the next build" } + } + scheduler.shutdown() + }, delay.inWholeMilliseconds, TimeUnit.MILLISECONDS) + } + + override fun onCancel() { + scheduler.shutdownNow() + } +} \ No newline at end of file diff --git a/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/watcher/ClasspathWatcher.kt b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/watcher/ClasspathWatcher.kt new file mode 100644 index 000000000..25d456052 --- /dev/null +++ b/BotCommands-restarter/src/main/kotlin/dev/freya02/botcommands/internal/restart/watcher/ClasspathWatcher.kt @@ -0,0 +1,230 @@ +package dev.freya02.botcommands.internal.restart.watcher + +import dev.freya02.botcommands.internal.restart.Restarter +import dev.freya02.botcommands.internal.restart.sources.DeletedSourceFile +import dev.freya02.botcommands.internal.restart.sources.SourceFile +import dev.freya02.botcommands.internal.restart.sources.SourceFiles +import dev.freya02.botcommands.internal.restart.sources.plus +import dev.freya02.botcommands.internal.restart.utils.AppClasspath +import dev.freya02.botcommands.internal.restart.utils.walkDirectories +import dev.freya02.botcommands.internal.restart.utils.walkFiles +import io.github.freya022.botcommands.api.core.utils.joinAsList +import io.github.oshai.kotlinlogging.KotlinLogging +import java.nio.file.FileSystems +import java.nio.file.Path +import java.nio.file.StandardWatchEventKinds.* +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.thread +import kotlin.concurrent.withLock +import kotlin.io.path.absolutePathString +import kotlin.io.path.isDirectory +import kotlin.io.path.pathString +import kotlin.io.path.relativeTo +import kotlin.time.Duration + +private val logger = KotlinLogging.logger { } + +// Lightweight, singleton version of [[SourceDirectories]] + [[ClasspathListener]] +internal class ClasspathWatcher private constructor( + 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/BotCommands-restarter.properties b/BotCommands-restarter/src/main/resources/META-INF/BotCommands-restarter.properties new file mode 100644 index 000000000..bc0eb5969 --- /dev/null +++ b/BotCommands-restarter/src/main/resources/META-INF/BotCommands-restarter.properties @@ -0,0 +1,13 @@ +# This file is for BotCommands's own tests +# It is in the production resources so it can be applied regardless of in which module it is used +# This principally excludes source sets which are used by the restarter, to avoid classpath issues + +restart.exclude.jda-keepalive-prod=BotCommands-jda-keepalive/build/classes/(?:kotlin|java)/main +restart.exclude.jda-keepalive-prod-res=BotCommands-jda-keepalive/build/resources/main + +restart.exclude.restarter-prod=BotCommands-restarter/build/classes/(?:kotlin|java)/main +restart.exclude.restarter-prod-res=BotCommands-restarter/build/resources/main + +# Have to use a negative lookbehind as the main module doesn't have a dedicated directory +restart.exclude.core-prod=(?() - jda.shutdown() - - if (!jda.awaitShutdown(jdaConfiguration.devTools.shutdownTimeout)) { - logger.warn { "Timed out waiting for JDA to shutdown, forcing" } - - jda.shutdownNow() - jda.awaitShutdown() - } else { - logger.info { "JDA has gracefully shut down" } - } + @EventListener(ContextClosedEvent::class) + internal fun onContextClosed() { + context.shutdownNow() } -} \ No newline at end of file +} diff --git a/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/BotCommandsConfigurations.kt b/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/BotCommandsConfigurations.kt index e1ccf25cd..df57f1fb4 100644 --- a/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/BotCommandsConfigurations.kt +++ b/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/BotCommandsConfigurations.kt @@ -25,7 +25,10 @@ internal class BotCommandsCoreConfiguration( override val ignoredIntents: Set = emptySet(), override val ignoredEventIntents: Set> = emptySet(), override val ignoreRestRateLimiter: Boolean = false, + override val enableShutdownHook: Boolean = true, ) : BConfig { + + override val args: Nothing get() = unusable() override val classGraphProcessors: Nothing get() = unusable() override val serviceConfig: Nothing get() = unusable() override val databaseConfig: Nothing get() = unusable() @@ -36,9 +39,11 @@ internal class BotCommandsCoreConfiguration( override val modalsConfig: Nothing get() = unusable() override val componentsConfig: Nothing get() = unusable() override val coroutineScopesConfig: Nothing get() = unusable() + override val restartConfig: Nothing get() = unusable() } -internal fun BConfigBuilder.applyConfig(configuration: BotCommandsCoreConfiguration) = apply { +internal fun BConfigBuilder.applyConfig(configuration: BotCommandsCoreConfiguration, jdaConfiguration: JDAConfiguration) = apply { + // args is assigned in builder predefinedOwnerIds += configuration.predefinedOwnerIds packages += configuration.packages classes += configuration.classes diff --git a/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/ConfigProvider.kt b/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/ConfigProvider.kt index 94dc064e0..7c48339dd 100644 --- a/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/ConfigProvider.kt +++ b/BotCommands-spring/src/main/kotlin/io/github/freya022/botcommands/internal/core/config/ConfigProvider.kt @@ -1,6 +1,7 @@ package io.github.freya022.botcommands.internal.core.config import io.github.freya022.botcommands.api.core.config.* +import org.springframework.boot.ApplicationArguments import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Primary @@ -10,7 +11,8 @@ internal open class ConfigProvider { @Bean @Primary internal open fun bConfig( - coreConfiguration: BotCommandsCoreConfiguration, coreConfigurers: List, + applicationArguments: ApplicationArguments, + coreConfiguration: BotCommandsCoreConfiguration, coreConfigurers: List, jdaConfiguration: JDAConfiguration, databaseConfiguration: BotCommandsDatabaseConfiguration, databaseConfigurers: List, appEmojisConfiguration: BotCommandsAppEmojisConfiguration, appEmojisConfigurers: List, textConfiguration: BotCommandsTextConfiguration, textConfigurers: List, @@ -20,8 +22,8 @@ internal open class ConfigProvider { componentsConfiguration: BotCommandsComponentsConfiguration, componentsConfigurers: List, coroutineConfigurers: List ): BConfig = - BConfigBuilder() - .applyConfig(coreConfiguration) + BConfigBuilder(applicationArguments.sourceArgs.toList()) + .applyConfig(coreConfiguration, jdaConfiguration) .apply { databaseConfig.applyConfig(databaseConfiguration).configure(databaseConfigurers) appEmojisConfig.applyConfig(appEmojisConfiguration).configure(appEmojisConfigurers) diff --git a/settings.gradle.kts b/settings.gradle.kts index b13e33fb7..d01b037d7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -4,3 +4,5 @@ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") include(":spring-properties-processor") include(":BotCommands-spring") +include(":BotCommands-restarter") +include(":BotCommands-jda-keepalive") diff --git a/src/examples/kotlin/io/github/freya022/bot/Main.kt b/src/examples/kotlin/io/github/freya022/bot/Main.kt index eaee484f0..0ce971693 100644 --- a/src/examples/kotlin/io/github/freya022/bot/Main.kt +++ b/src/examples/kotlin/io/github/freya022/bot/Main.kt @@ -1,5 +1,6 @@ package io.github.freya022.bot +import ch.qos.logback.classic.ClassicConstants as LogbackConstants import dev.reformator.stacktracedecoroutinator.jvm.DecoroutinatorJvmApi import io.github.freya022.bot.config.Config import io.github.freya022.bot.config.Environment @@ -10,7 +11,6 @@ import net.dv8tion.jda.api.interactions.DiscordLocale import java.lang.management.ManagementFactory import kotlin.io.path.absolutePathString import kotlin.system.exitProcess -import ch.qos.logback.classic.ClassicConstants as LogbackConstants private val logger by lazy { KotlinLogging.logger {} } // Must not load before system property is set @@ -39,7 +39,7 @@ object Main { val config = Config.instance - BotCommands.create { + BotCommands.create(args) { disableExceptionsInDMs = Environment.isDev addPredefinedOwners(*config.ownerIds.toLongArray()) diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/commands/ratelimit/handler/DefaultRateLimitHandler.kt b/src/main/kotlin/io/github/freya022/botcommands/api/commands/ratelimit/handler/DefaultRateLimitHandler.kt index 5d58f68d2..3689716f8 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/api/commands/ratelimit/handler/DefaultRateLimitHandler.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/api/commands/ratelimit/handler/DefaultRateLimitHandler.kt @@ -29,7 +29,7 @@ import java.time.Instant import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.nanoseconds -private val deleteScope = namedDefaultScope("Rate limit message delete", 1) +private val deleteScope = namedDefaultScope("Rate limit message delete", 1, isDaemon = true) /** * Default [RateLimitHandler] implementation based on [rate limit scopes][RateLimitScope]. diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/core/BContext.kt b/src/main/kotlin/io/github/freya022/botcommands/api/core/BContext.kt index 0f6979c00..a20ae81ef 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/api/core/BContext.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/api/core/BContext.kt @@ -11,6 +11,9 @@ import io.github.freya022.botcommands.api.core.service.annotations.InterfacedSer import io.github.freya022.botcommands.api.core.service.getService import io.github.freya022.botcommands.internal.core.exceptions.ServiceException import net.dv8tion.jda.api.JDA +import java.time.Duration as JavaDuration +import kotlin.time.Duration +import kotlin.time.toKotlinDuration /** * Main context for BotCommands framework. @@ -43,7 +46,11 @@ interface BContext { * * Fires [BReadyEvent]. */ - READY + READY, + + SHUTTING_DOWN, + + SHUTDOWN, } //region Configs @@ -62,6 +69,8 @@ interface BContext { get() = config.appEmojisConfig val textConfig: BTextConfig get() = config.textConfig + val restartConfig: BRestartConfig + get() = config.restartConfig //endregion //region Services @@ -144,6 +153,16 @@ interface BContext { */ fun getExceptionContent(message: String, t: Throwable?, extraContext: Map): String + fun shutdown() + + fun shutdownNow() + + fun awaitShutdown(): Boolean = awaitShutdown(Duration.INFINITE) + + fun awaitShutdown(timeout: JavaDuration): Boolean = awaitShutdown(timeout.toKotlinDuration()) + + fun awaitShutdown(timeout: Duration): Boolean + /** * Returns the [TextCommandsContext] service. * diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/core/BotCommands.kt b/src/main/kotlin/io/github/freya022/botcommands/api/core/BotCommands.kt index 96955e6c4..7b53a660e 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/api/core/BotCommands.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/api/core/BotCommands.kt @@ -40,8 +40,9 @@ object BotCommands { */ @JvmStatic @JvmName("create") + @Deprecated(message = "Replaced with create(String, ReceiverConsumer)", ReplaceWith("create(args, configConsumer)")) fun createJava(configConsumer: ReceiverConsumer): BContext { - return create(configConsumer = configConsumer) + return create(emptyArray(), configConsumer = configConsumer) } /** @@ -55,14 +56,48 @@ object BotCommands { * @see BotCommands */ @JvmSynthetic + @Deprecated(message = "Replaced with create(String, ReceiverConsumer)", ReplaceWith("create(args, configConsumer)")) fun create(configConsumer: BConfigBuilder.() -> Unit): BContext { - return build(BConfigBuilder().apply(configConsumer).build()) + return build(BConfigBuilder(emptyList()).apply(configConsumer).build()) + } + + /** + * Creates a new instance of the framework. + * + * @return The context for the newly created framework instance, + * while this is returned, using it *usually* is not a good idea, + * your architecture should rely on [dependency injection](https://bc.freya02.dev/3.X/using-botcommands/dependency-injection/) + * and events instead. + * + * @see BotCommands + */ + @JvmStatic + @JvmName("create") + fun createJava(args: Array, configConsumer: ReceiverConsumer): BContext { + return create(args, configConsumer = configConsumer) + } + + /** + * Creates a new instance of the framework. + * + * @return The context for the newly created framework instance, + * while this is returned, using it *usually* is not a good idea, + * your architecture should rely on [dependency injection](https://bc.freya02.dev/3.X/using-botcommands/dependency-injection/) + * and events instead. + * + * @see BotCommands + */ + @JvmSynthetic + fun create(args: Array, configConsumer: BConfigBuilder.() -> Unit): BContext { + return build(BConfigBuilder(args.toList()).apply(configConsumer).build()) } private fun build(config: BConfig): BContext { val (context, duration) = measureTimedValue { val bootstrap = BCBotCommandsBootstrap(config) - bootstrap.injectAndLoadServices() + bootstrap.injectServices() + bootstrap.signalStart() + bootstrap.loadServices() bootstrap.loadContext() bootstrap.serviceContainer.getService() } diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BConfig.kt b/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BConfig.kt index 747c69665..6863e4d7f 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BConfig.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BConfig.kt @@ -2,6 +2,7 @@ package io.github.freya022.botcommands.api.core.config import io.github.freya022.botcommands.api.ReceiverConsumer import io.github.freya022.botcommands.api.commands.text.annotations.Hidden +import io.github.freya022.botcommands.api.core.BotCommands import io.github.freya022.botcommands.api.core.BotOwners import io.github.freya022.botcommands.api.core.annotations.BEventListener import io.github.freya022.botcommands.api.core.requests.PriorityGlobalRestRateLimiter @@ -12,6 +13,7 @@ import io.github.freya022.botcommands.api.core.utils.loggerOf import io.github.freya022.botcommands.api.core.utils.toImmutableList import io.github.freya022.botcommands.api.core.utils.toImmutableSet import io.github.freya022.botcommands.api.core.waiter.EventWaiter +import io.github.freya022.botcommands.api.restart.ExperimentalRestartApi import io.github.freya022.botcommands.internal.core.config.ConfigDSL import io.github.freya022.botcommands.internal.core.config.ConfigurationValue import io.github.oshai.kotlinlogging.KotlinLogging @@ -23,6 +25,15 @@ import org.intellij.lang.annotations.Language @InjectedService interface BConfig { + + /** + * The list of arguments passed to this program's entry point. + * + * This property is supplied by the entry point ([BotCommands] or Spring), + * thus it has no writable property for it. + */ + val args: List + /** * Predefined user IDs of the bot owners, allowing bypassing cooldowns, user permission checks, * and having [hidden commands][Hidden] shown. @@ -107,6 +118,9 @@ interface BConfig { val classGraphProcessors: List + @ConfigurationValue("botcommands.core.enableShutdownHook", defaultValue = "false") + val enableShutdownHook: Boolean + val serviceConfig: BServiceConfig val databaseConfig: BDatabaseConfig val localizationConfig: BLocalizationConfig @@ -116,10 +130,13 @@ interface BConfig { val modalsConfig: BModalsConfig val componentsConfig: BComponentsConfig val coroutineScopesConfig: BCoroutineScopesConfig + val restartConfig: BRestartConfig } @ConfigDSL -class BConfigBuilder : BConfig { +class BConfigBuilder( + override val args: List, +) : BConfig { override val packages: MutableSet = HashSet() override val classes: MutableSet> = HashSet() @@ -138,6 +155,8 @@ class BConfigBuilder : BConfig { override val classGraphProcessors: MutableList = arrayListOf() + override var enableShutdownHook: Boolean = true + override val serviceConfig = BServiceConfigBuilder() override val databaseConfig = BDatabaseConfigBuilder() override val localizationConfig = BLocalizationConfigBuilder() @@ -147,6 +166,8 @@ class BConfigBuilder : BConfig { override val modalsConfig = BModalsConfigBuilder() override val componentsConfig = BComponentsConfigBuilder() override val coroutineScopesConfig = BCoroutineScopesConfigBuilder() + @ExperimentalRestartApi + override val restartConfig = BRestartConfigBuilder() /** * Predefined user IDs of the bot owners, allowing bypassing cooldowns, user permission checks, @@ -272,12 +293,18 @@ class BConfigBuilder : BConfig { componentsConfig.apply(block) } + @ExperimentalRestartApi + fun restart(block: ReceiverConsumer) { + restartConfig.apply(block) + } + fun build(): BConfig { val logger = KotlinLogging.loggerOf() if (disableExceptionsInDMs) logger.info { "Disabled sending exception in bot owners DMs" } return object : BConfig { + override val args = this@BConfigBuilder.args.toImmutableList() override val predefinedOwnerIds = this@BConfigBuilder.predefinedOwnerIds.toImmutableSet() override val packages = this@BConfigBuilder.packages.toImmutableSet() override val classes = this@BConfigBuilder.classes.toImmutableSet() @@ -287,6 +314,7 @@ class BConfigBuilder : BConfig { override val ignoredEventIntents = this@BConfigBuilder.ignoredEventIntents.toImmutableSet() override val ignoreRestRateLimiter = this@BConfigBuilder.ignoreRestRateLimiter override val classGraphProcessors = this@BConfigBuilder.classGraphProcessors.toImmutableList() + override val enableShutdownHook = this@BConfigBuilder.enableShutdownHook override val serviceConfig = this@BConfigBuilder.serviceConfig.build() override val databaseConfig = this@BConfigBuilder.databaseConfig.build() override val localizationConfig = this@BConfigBuilder.localizationConfig.build() @@ -296,6 +324,8 @@ class BConfigBuilder : BConfig { override val modalsConfig = this@BConfigBuilder.modalsConfig.build() override val componentsConfig = this@BConfigBuilder.componentsConfig.build() override val coroutineScopesConfig = this@BConfigBuilder.coroutineScopesConfig.build() + @ExperimentalRestartApi + override val restartConfig = this@BConfigBuilder.restartConfig.build() } } } diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BRestartConfig.kt b/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BRestartConfig.kt new file mode 100644 index 000000000..93cf47fad --- /dev/null +++ b/src/main/kotlin/io/github/freya022/botcommands/api/core/config/BRestartConfig.kt @@ -0,0 +1,30 @@ +package io.github.freya022.botcommands.api.core.config + +import io.github.freya022.botcommands.api.core.service.annotations.InjectedService +import io.github.freya022.botcommands.api.restart.ExperimentalRestartApi +import io.github.freya022.botcommands.internal.core.config.ConfigDSL +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +@InjectedService +interface BRestartConfig { + // TODO document how to add agent (link wiki), fallback to dynamic loading + val cacheKey: String? + + // TODO java duration + val restartDelay: Duration +} + +@ExperimentalRestartApi +@ConfigDSL +class BRestartConfigBuilder : BRestartConfig { + + override var cacheKey: String? = null + + override var restartDelay: Duration = 1.seconds + + internal fun build() = object : BRestartConfig { + override val cacheKey = this@BRestartConfigBuilder.cacheKey + override val restartDelay = this@BRestartConfigBuilder.restartDelay + } +} diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/core/db/Database.kt b/src/main/kotlin/io/github/freya022/botcommands/api/core/db/Database.kt index c1dba23c0..b68cfe09e 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/api/core/db/Database.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/api/core/db/Database.kt @@ -166,7 +166,7 @@ internal fun Database.withStatementJava(sql: String, readOnly: Boolean = fal } @PublishedApi -internal val dbLeakScope = namedDefaultScope("Connection leak watcher", 1) +internal val dbLeakScope = namedDefaultScope("Connection leak watcher", 1, isDaemon = true) private val currentTransaction = ThreadLocal() diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/core/events/ApplicationStartListener.kt b/src/main/kotlin/io/github/freya022/botcommands/api/core/events/ApplicationStartListener.kt new file mode 100644 index 000000000..b3da7253b --- /dev/null +++ b/src/main/kotlin/io/github/freya022/botcommands/api/core/events/ApplicationStartListener.kt @@ -0,0 +1,9 @@ +package io.github.freya022.botcommands.api.core.events + +import io.github.freya022.botcommands.api.core.service.annotations.InterfacedService + +@InterfacedService(acceptMultiple = true) +fun interface ApplicationStartListener { + + fun onApplicationStart(event: BApplicationStartEvent) +} \ No newline at end of file diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/core/events/BApplicationStartEvent.kt b/src/main/kotlin/io/github/freya022/botcommands/api/core/events/BApplicationStartEvent.kt new file mode 100644 index 000000000..caaf9020f --- /dev/null +++ b/src/main/kotlin/io/github/freya022/botcommands/api/core/events/BApplicationStartEvent.kt @@ -0,0 +1,8 @@ +package io.github.freya022.botcommands.api.core.events + +import io.github.freya022.botcommands.api.core.config.BConfig + +class BApplicationStartEvent internal constructor( + val config: BConfig, + val args: List, +) \ No newline at end of file diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/core/events/BShutdownEvent.kt b/src/main/kotlin/io/github/freya022/botcommands/api/core/events/BShutdownEvent.kt new file mode 100644 index 000000000..89669b0cc --- /dev/null +++ b/src/main/kotlin/io/github/freya022/botcommands/api/core/events/BShutdownEvent.kt @@ -0,0 +1,5 @@ +package io.github.freya022.botcommands.api.core.events + +import io.github.freya022.botcommands.api.core.BContext + +class BShutdownEvent(context: BContext) : BEvent(context) \ No newline at end of file diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/annotations/RequiresDefaultInjection.kt b/src/main/kotlin/io/github/freya022/botcommands/api/core/service/annotations/RequiresDefaultInjection.kt similarity index 65% rename from src/main/kotlin/io/github/freya022/botcommands/internal/core/service/annotations/RequiresDefaultInjection.kt rename to src/main/kotlin/io/github/freya022/botcommands/api/core/service/annotations/RequiresDefaultInjection.kt index 71458a7e7..fa6ca1b77 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/annotations/RequiresDefaultInjection.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/api/core/service/annotations/RequiresDefaultInjection.kt @@ -1,4 +1,4 @@ -package io.github.freya022.botcommands.internal.core.service.annotations +package io.github.freya022.botcommands.api.core.service.annotations import io.github.freya022.botcommands.internal.core.service.BCInjectionCondition import org.springframework.context.annotation.Conditional @@ -7,4 +7,4 @@ import org.springframework.context.annotation.Conditional * Makes a service disabled when using Spring */ @Conditional(BCInjectionCondition::class) -internal annotation class RequiresDefaultInjection +annotation class RequiresDefaultInjection diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/core/utils/Utils.kt b/src/main/kotlin/io/github/freya022/botcommands/api/core/utils/Utils.kt index 149a6e11a..e4a3cedbe 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/api/core/utils/Utils.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/api/core/utils/Utils.kt @@ -69,6 +69,7 @@ fun withResource(url: String, block: (InputStream) -> R): R { * * @param name The base name of the threads and coroutines, will be prefixed by the number if [corePoolSize] > 1 * @param corePoolSize The number of threads to keep in the pool, even if they are idle + * @param isDaemon If the threads are daemons, the JVM exits when all threads are daemons * @param job The parent job used for coroutines which can be used to cancel all children, uses [SupervisorJob] by default * @param errorHandler The [CoroutineExceptionHandler] used for handling uncaught exceptions, * uses a logging handler which cancels the parent job on [Error] by default @@ -77,6 +78,7 @@ fun withResource(url: String, block: (InputStream) -> R): R { fun namedDefaultScope( name: String, corePoolSize: Int, + isDaemon: Boolean = false, job: Job? = null, errorHandler: CoroutineExceptionHandler? = null, context: CoroutineContext = EmptyCoroutineContext @@ -96,6 +98,8 @@ fun namedDefaultScope( this.name = "$name ${++count}" } } + + this.isDaemon = isDaemon } } diff --git a/src/main/kotlin/io/github/freya022/botcommands/api/restart/ExperimentalRestartApi.kt b/src/main/kotlin/io/github/freya022/botcommands/api/restart/ExperimentalRestartApi.kt new file mode 100644 index 000000000..1cad6b510 --- /dev/null +++ b/src/main/kotlin/io/github/freya022/botcommands/api/restart/ExperimentalRestartApi.kt @@ -0,0 +1,17 @@ +package io.github.freya022.botcommands.api.restart + +/** + * Opt-in marker annotation for the hot reloading feature. + * + * This feature provides no guarantee and its API may change (including removals) at any time. + * + * Please create an issue if you encounter a problem, including if it needs adaptations for your use case. + */ +@RequiresOptIn( + message = "This feature is experimental, please see the documentation of this opt-in annotation (@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/src/main/kotlin/io/github/freya022/botcommands/internal/components/controller/ComponentTimeoutManager.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/components/controller/ComponentTimeoutManager.kt index 370ada49e..558ea131e 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/components/controller/ComponentTimeoutManager.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/components/controller/ComponentTimeoutManager.kt @@ -15,7 +15,6 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.datetime.Clock import kotlinx.datetime.Instant -import kotlin.collections.set private val logger = KotlinLogging.logger { } diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt index 258199e36..723fc6e5e 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/BContextImpl.kt @@ -1,25 +1,48 @@ package io.github.freya022.botcommands.internal.core +import dev.minn.jda.ktx.events.CoroutineEventManager import io.github.freya022.botcommands.api.BCInfo import io.github.freya022.botcommands.api.core.BContext import io.github.freya022.botcommands.api.core.BContext.Status import io.github.freya022.botcommands.api.core.BotOwners import io.github.freya022.botcommands.api.core.GlobalExceptionHandler import io.github.freya022.botcommands.api.core.config.BConfig +import io.github.freya022.botcommands.api.core.config.BCoroutineScopesConfig +import io.github.freya022.botcommands.api.core.events.BShutdownEvent import io.github.freya022.botcommands.api.core.events.BStatusChangeEvent import io.github.freya022.botcommands.api.core.hooks.EventDispatcher import io.github.freya022.botcommands.api.core.service.ServiceContainer import io.github.freya022.botcommands.api.core.service.annotations.BService +import io.github.freya022.botcommands.api.core.service.getService import io.github.freya022.botcommands.api.core.service.getServiceOrNull import io.github.freya022.botcommands.api.core.service.lazy +import io.github.freya022.botcommands.api.core.utils.awaitShutdown import io.github.freya022.botcommands.api.core.utils.loggerOf import io.github.freya022.botcommands.internal.commands.application.ApplicationCommandsContextImpl import io.github.freya022.botcommands.internal.commands.text.TextCommandsContextImpl +import io.github.freya022.botcommands.internal.utils.takeIfFinite import io.github.freya022.botcommands.internal.utils.unwrap import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExecutorCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.datetime.Clock +import net.dv8tion.jda.api.JDA import net.dv8tion.jda.api.entities.Message +import net.dv8tion.jda.api.events.session.ShutdownEvent import net.dv8tion.jda.api.exceptions.ErrorHandler import net.dv8tion.jda.api.requests.ErrorResponse +import java.util.concurrent.ExecutorService +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import java.util.concurrent.locks.Condition +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock +import kotlin.reflect.full.declaredMemberProperties +import kotlin.reflect.jvm.jvmErasure +import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes private val logger = KotlinLogging.loggerOf() @@ -32,8 +55,8 @@ internal class BContextImpl internal constructor( ) : BContext { override val eventDispatcher: EventDispatcher by serviceContainer.lazy() - override var status: Status = Status.PRE_LOAD - private set + private val _status: AtomicReference = AtomicReference(Status.PRE_LOAD) + override val status: Status get() = _status.get() override val globalExceptionHandler: GlobalExceptionHandler? by lazy { serviceContainer.getServiceOrNull() } @@ -44,6 +67,9 @@ internal class BContextImpl internal constructor( private val bcRegex = Regex("at ${Regex.escape("io.github.freya022.botcommands.")}(?:api|internal)[.a-z]*\\.(.+)") private var nextExceptionDispatch: Long = 0 + private val statusLock: ReentrantLock = ReentrantLock() + private val statusCondition: Condition = statusLock.newCondition() + override fun dispatchException(message: String, t: Throwable?, extraContext: Map) { if (config.disableExceptionsInDMs) return //Don't send DM exceptions in dev mode @@ -118,8 +144,172 @@ internal class BContextImpl internal constructor( } internal suspend fun setStatus(newStatus: Status) { - val oldStatus = this.status - this.status = newStatus - eventDispatcher.dispatchEvent(BStatusChangeEvent(this@BContextImpl, oldStatus, newStatus)) + val oldStatus = statusLock.withLock { + val oldStatus = _status.getAndSet(newStatus) + statusCondition.signalAll() + oldStatus + } + if (oldStatus != newStatus) + eventDispatcher.dispatchEvent(BStatusChangeEvent(this@BContextImpl, oldStatus, newStatus)) + } + + override fun shutdown() { + if (status == Status.SHUTTING_DOWN || status == Status.SHUTDOWN) return + + // Shutdown hook will be removed by [[BCShutdownHook]] if we use the built-in DI + runBlocking { setStatus(Status.SHUTTING_DOWN) } + + scheduleShutdownSignal(afterShutdownSignal = { + shutdownEventManagerScope(now = false) + shutdownCoroutineScopes(now = false) + }) + + shutdownJDA(now = false) + } + + override fun shutdownNow() { + // Do not call shutdown(), more precisely do not call scheduleShutdownSignal() twice + if (status == Status.SHUTTING_DOWN || status == Status.SHUTDOWN) return + + // Shutdown hook will be removed by [[BCShutdownHook]] if we use the built-in DI + runBlocking { setStatus(Status.SHUTTING_DOWN) } + + scheduleShutdownSignal(afterShutdownSignal = { + shutdownEventManagerScope(now = true) + shutdownCoroutineScopes(now = true) + }) + + shutdownJDA(now = true) + } + + /** + * Schedules a [BShutdownEvent] when all shards are shut down. + * + * A shard is considered shut down once its requester is shutdown, + * meaning no request can go through anymore. + * + * This does not necessarily mean all activities are stopped outside JDA. + * + * [afterShutdownSignal] will only run once. + */ + private fun scheduleShutdownSignal(afterShutdownSignal: () -> Unit) { + fun signalShutdown() = runBlocking { + statusLock.withLock { + if (status == Status.SHUTDOWN) return@runBlocking afterShutdownSignal() + } + setStatus(Status.SHUTDOWN) + eventDispatcher.dispatchEvent(BShutdownEvent(this@BContextImpl)) + // Shutdown the pools *after* dispatching + afterShutdownSignal() + } + + val jda = jdaOrNull + if (jda == null) { + logger.debug { "Immediately sending shutdown signal as there is no JDA instance registered" } + signalShutdown() + return + } + + val countdown = AtomicInteger(jda.shardManager?.shardsRunning ?: 1) + val shards = jda.shardManager?.shards ?: listOf(jda) + shards.forEach { + it.listenOnce(ShutdownEvent::class.java).subscribe { + if (countdown.decrementAndGet() == 0) { + signalShutdown() + } + } + } } + + override fun awaitShutdown(timeout: Duration): Boolean { + val deadline = Clock.System.now() + (timeout.takeIfFinite() ?: Duration.INFINITE) + fun durationUntilDeadline(): Duration = deadline - Clock.System.now() + + if (!awaitJDAShutdown(::durationUntilDeadline)) + return false + + statusLock.withLock { + while (status != Status.SHUTDOWN) { + if (!statusCondition.await(durationUntilDeadline().inWholeMilliseconds, TimeUnit.MILLISECONDS)) { + return false + } + } + } + + return true + } + + private fun awaitJDAShutdown(durationUntilDeadlineFn: () -> Duration): Boolean { + val jda = jdaOrNull + if (jda == null) { + logger.debug { "Not awaiting JDA shutdown as there is no JDA instance registered" } + return true + } + val shardManager = jda.shardManager + if (shardManager != null) { + shardManager.shards.forEach { shard -> + if (!shard.awaitShutdown(durationUntilDeadlineFn())) { + return false + } + } + } else { + if (!jda.awaitShutdown(durationUntilDeadlineFn())) { + return false + } + } + + return true + } + + private fun shutdownJDA(now: Boolean) { + val jda = jdaOrNull ?: return logger.debug { "Ignoring JDA shutdown as there is no JDA instance registered" } + + val shardManager = jda.shardManager + if (shardManager != null) { + shardManager.shutdown() + + if (now) { + shardManager.shardCache.forEach { jda -> + // The shard manager may not be configured to shut down immediately, + // so we try to force it here + jda.shutdownNow() + } + } + } else { + if (now) { + jda.shutdownNow() + } else { + jda.shutdown() + } + } + } + + private fun shutdownEventManagerScope(now: Boolean) { + getService().shutdownExecutor(now = now) + } + + private fun shutdownCoroutineScopes(now: Boolean) { + BCoroutineScopesConfig::class + .declaredMemberProperties + .asSequence() + .filter { it.returnType.jvmErasure == CoroutineScope::class } + .map { it.get(coroutineScopesConfig) } + .map { it as CoroutineScope } + .forEach { + it.shutdownExecutor(now = now) + } + } + + private fun CoroutineScope.shutdownExecutor(now: Boolean) { + val executor = coroutineContext[ExecutorCoroutineDispatcher]?.executor as? ExecutorService ?: return + if (now) { + cancel("Cancelled by shutdown") + executor.shutdownNow() + } else { + executor.shutdown() + } + } + + // TODO fails with spring + private val jdaOrNull: JDA? get() = getServiceOrNull() } diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/BotOwnersImpl.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/BotOwnersImpl.kt index 302d9df3d..9faf841a4 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/BotOwnersImpl.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/BotOwnersImpl.kt @@ -44,7 +44,7 @@ internal class BotOwnersImpl internal constructor( override fun isOwner(user: UserSnowflake): Boolean = user.idLong in owners - @BEventListener + @BEventListener(mode = BEventListener.RunMode.ASYNC) internal suspend fun onInjectedJDA(event: InjectedJDAEvent) { if (ownerWriter.isInitialized()) return diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/ReadyListener.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/ReadyListener.kt index e0a13bf85..5826f6398 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/ReadyListener.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/ReadyListener.kt @@ -25,7 +25,8 @@ internal class ReadyListener { @BEventListener(priority = Int.MAX_VALUE, mode = RunMode.BLOCKING) internal suspend fun onConnectEvent(event: StatusChangeEvent, context: BContext) { // At this point, JDA should be usable - if (!connected && event.newStatus == JDA.Status.CONNECTING_TO_WEBSOCKET) { + // Use >= because any event beyond this point is a valid JDA instance + if (!connected && event.newStatus >= JDA.Status.CONNECTING_TO_WEBSOCKET) { lock.withLock { if (connected) return connected = true diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/EventDispatcherImpl.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/EventDispatcherImpl.kt index 7a4f28f9e..1f0469566 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/EventDispatcherImpl.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/hooks/EventDispatcherImpl.kt @@ -23,7 +23,12 @@ internal class EventDispatcherImpl internal constructor( ) : EventDispatcher() { private val inheritedCoroutineScope: CoroutineScope = originalCoroutineEventManager + private val inheritedCoroutineScopeExecutorDispatcher: ExecutorCoroutineDispatcher? = + originalCoroutineEventManager.coroutineContext[ExecutorCoroutineDispatcher] + private val asyncCoroutineScope: CoroutineScope = coroutineScopesConfig.eventDispatcherScope + private val asyncCoroutineScopeExecutorDispatcher: ExecutorCoroutineDispatcher? = + asyncCoroutineScope.coroutineContext[ExecutorCoroutineDispatcher] internal fun onEvent(event: GenericEvent) { // No need to check for `event` type as if it's in the map, then it's recognized @@ -39,13 +44,21 @@ internal class EventDispatcherImpl internal constructor( } // When the listener requests to run async - handlers[RunMode.ASYNC]?.forEach { eventHandler -> + handlers[RunMode.ASYNC]?.let { eventHandlers -> + if (!asyncCoroutineScope.isActive) + return@let runBlocking { eventHandlers.forEach { runEventHandler(it, event) } } + asyncCoroutineScope.launch { - runEventHandler(eventHandler, event) + eventHandlers.forEach { eventHandler -> + runEventHandler(eventHandler, event) + } } } handlers[RunMode.SHARED]?.let { eventHandlers -> + if (!inheritedCoroutineScope.isActive) + return@let runBlocking { eventHandlers.forEach { runEventHandler(it, event) } } + // Stick to what JDA-KTX does, 1 coroutine per event for all listeners inheritedCoroutineScope.launch { eventHandlers.forEach { eventHandler -> @@ -69,9 +82,15 @@ internal class EventDispatcherImpl internal constructor( } // When the listener requests to run async - handlers[RunMode.ASYNC]?.forEach { eventHandler -> + handlers[RunMode.ASYNC]?.let { eventHandlers -> + // TODO make sure this actually checks if the thread pool is open + if (!asyncCoroutineScope.isActive) + return@let runBlocking { eventHandlers.forEach { runEventHandler(it, event) } } + asyncCoroutineScope.launch { - runEventHandler(eventHandler, event) + eventHandlers.forEach { eventHandler -> + runEventHandler(eventHandler, event) + } } } diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCBotCommandsBootstrap.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCBotCommandsBootstrap.kt index c9e1f9ff5..40d937b5a 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCBotCommandsBootstrap.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCBotCommandsBootstrap.kt @@ -4,10 +4,9 @@ import io.github.classgraph.ClassInfo import io.github.classgraph.MethodInfo import io.github.freya022.botcommands.api.BCInfo import io.github.freya022.botcommands.api.core.config.* -import io.github.freya022.botcommands.api.core.service.ClassGraphProcessor -import io.github.freya022.botcommands.api.core.service.ServiceContainer -import io.github.freya022.botcommands.api.core.service.putServiceAs -import io.github.freya022.botcommands.api.core.service.putServiceWithTypeAlias +import io.github.freya022.botcommands.api.core.events.ApplicationStartListener +import io.github.freya022.botcommands.api.core.events.BApplicationStartEvent +import io.github.freya022.botcommands.api.core.service.* import io.github.freya022.botcommands.internal.core.Version import io.github.freya022.botcommands.internal.core.service.provider.ServiceProviders import net.dv8tion.jda.api.JDAInfo @@ -31,7 +30,7 @@ internal class BCBotCommandsBootstrap internal constructor( init() } - internal fun injectAndLoadServices() = measure("Created services") { + internal fun injectServices() { serviceContainer.putServiceWithTypeAlias(this) serviceContainer.putServiceWithTypeAlias(serviceContainer) @@ -47,7 +46,17 @@ internal class BCBotCommandsBootstrap internal constructor( serviceContainer.putServiceAs(config.componentsConfig) serviceContainer.putServiceAs(config.coroutineScopesConfig) serviceContainer.putServiceAs(config.textConfig) + serviceContainer.putServiceAs(config.restartConfig) + } + + // This does not use the usual event dispatcher to avoid more delays + internal fun signalStart() = measure("Signaled application start") { + val startEvent = BApplicationStartEvent(config, emptyList() /* TODO */) + val startListeners = serviceContainer.getInterfacedServices() + startListeners.forEach { it.onApplicationStart(startEvent) } + } + internal fun loadServices() = measure("Created services") { serviceContainer.loadServices() } diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCClassAnnotationsMap.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCClassAnnotationsMap.kt index 86384ecd5..49945cf99 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCClassAnnotationsMap.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCClassAnnotationsMap.kt @@ -1,8 +1,8 @@ package io.github.freya022.botcommands.internal.core.service import io.github.freya022.botcommands.api.core.service.annotations.BService +import io.github.freya022.botcommands.api.core.service.annotations.RequiresDefaultInjection import io.github.freya022.botcommands.api.core.service.annotations.ServiceType -import io.github.freya022.botcommands.internal.core.service.annotations.RequiresDefaultInjection import kotlin.reflect.KClass /** diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCInstantiableServices.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCInstantiableServices.kt index 85320da96..3dd08670b 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCInstantiableServices.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCInstantiableServices.kt @@ -3,10 +3,10 @@ package io.github.freya022.botcommands.internal.core.service import io.github.freya022.botcommands.api.core.service.ServiceError import io.github.freya022.botcommands.api.core.service.annotations.BService import io.github.freya022.botcommands.api.core.service.annotations.InterfacedService +import io.github.freya022.botcommands.api.core.service.annotations.RequiresDefaultInjection import io.github.freya022.botcommands.api.core.service.annotations.ServiceType import io.github.freya022.botcommands.api.core.utils.joinAsList import io.github.freya022.botcommands.api.core.utils.simpleNestedName -import io.github.freya022.botcommands.internal.core.service.annotations.RequiresDefaultInjection import io.github.freya022.botcommands.internal.core.service.provider.ServiceProvider import io.github.freya022.botcommands.internal.core.service.provider.ServiceProviders import io.github.freya022.botcommands.internal.utils.reference diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCShutdownHook.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCShutdownHook.kt new file mode 100644 index 000000000..3ad3fb612 --- /dev/null +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/core/service/BCShutdownHook.kt @@ -0,0 +1,55 @@ +package io.github.freya022.botcommands.internal.core.service + +import io.github.freya022.botcommands.api.core.BContext +import io.github.freya022.botcommands.api.core.annotations.BEventListener +import io.github.freya022.botcommands.api.core.config.BConfig +import io.github.freya022.botcommands.api.core.events.BStatusChangeEvent +import io.github.freya022.botcommands.api.core.events.PostLoadEvent +import io.github.freya022.botcommands.api.core.service.ConditionalServiceChecker +import io.github.freya022.botcommands.api.core.service.ServiceContainer +import io.github.freya022.botcommands.api.core.service.annotations.BService +import io.github.freya022.botcommands.api.core.service.annotations.ConditionalService +import io.github.freya022.botcommands.api.core.service.annotations.Lazy +import io.github.freya022.botcommands.api.core.service.annotations.RequiresDefaultInjection +import io.github.freya022.botcommands.api.core.service.getService + +@Lazy +@BService +@ConditionalService(BCShutdownHook.ActivationCondition::class) +@RequiresDefaultInjection +internal class BCShutdownHook internal constructor( + context: BContext, +) { + + private val hook = Thread { context.shutdownNow() } + + @BEventListener + internal fun registerShutdownHook(event: PostLoadEvent) { + Runtime.getRuntime().addShutdownHook(hook) + } + + @BEventListener + internal fun onShuttingDown(event: BStatusChangeEvent) { + if (event.newStatus == BContext.Status.SHUTTING_DOWN) { + try { + Runtime.getRuntime().removeShutdownHook(hook) + } catch (_: IllegalStateException) { + // + } + } + } + + internal object ActivationCondition : ConditionalServiceChecker { + + override fun checkServiceAvailability( + serviceContainer: ServiceContainer, + checkedClass: Class<*> + ): String? { + if (!serviceContainer.getService().enableShutdownHook) { + return "Default shutdown hook is disabled" + } + + return null + } + } +} diff --git a/src/main/kotlin/io/github/freya022/botcommands/internal/utils/ReflectionMetadata.kt b/src/main/kotlin/io/github/freya022/botcommands/internal/utils/ReflectionMetadata.kt index 9396e622f..48520b4d0 100644 --- a/src/main/kotlin/io/github/freya022/botcommands/internal/utils/ReflectionMetadata.kt +++ b/src/main/kotlin/io/github/freya022/botcommands/internal/utils/ReflectionMetadata.kt @@ -79,7 +79,7 @@ private fun ReflectionMetadata.getMethodMetadataOrNull(function: KFunction<*>): private class ReflectionMetadataScanner private constructor( private val config: BConfig, - private val bootstrap: BotCommandsBootstrap + private val bootstrap: BotCommandsBootstrap, ) { private val classGraphProcessors: List = @@ -106,6 +106,8 @@ private class ReflectionMetadataScanner private constructor( .acceptPackages( "io.github.freya022.botcommands.api", "io.github.freya022.botcommands.internal", + "dev.freya02.botcommands.api", + "dev.freya02.botcommands.internal", *packages.toTypedArray() ) .acceptClasses(*classes.mapToArray { it.name }) @@ -140,6 +142,7 @@ private class ReflectionMetadataScanner private constructor( private fun ClassInfo.isFromLib() = packageName.startsWith("io.github.freya022.botcommands.api") || packageName.startsWith("io.github.freya022.botcommands.internal") + || packageName.startsWith("dev.freya02.botcommands.api") || packageName.startsWith("dev.freya02.botcommands.internal") private fun List.filterLibraryClasses(): List { // Get types referenced by factories so we get metadata from those as well @@ -198,7 +201,7 @@ private class ReflectionMetadataScanner private constructor( private fun List.processClasses(): List { return onEach { classInfo -> try { - val kClass = tryGetClass(classInfo) ?: return@onEach + val kClass = tryGetClass(classInfo)?.kotlin ?: return@onEach processMethods(classInfo, kClass) @@ -212,18 +215,16 @@ private class ReflectionMetadataScanner private constructor( } } - private fun tryGetClass(classInfo: ClassInfo): KClass<*>? { + private fun tryGetClass(classInfo: ClassInfo): Class<*>? { // Ignore unknown classes return try { - classInfo.loadClass().kotlin - } catch(e: IllegalArgumentException) { - // ClassGraph wraps Class#forName exceptions in an IAE - val cause = e.cause - if (cause is ClassNotFoundException || cause is NoClassDefFoundError) { + loadClass(classInfo.name) + } catch (e: Throwable) { + if (e is ClassNotFoundException || e is NoClassDefFoundError) { return if (logger.isTraceEnabled()) { logger.traceNull(e) { "Ignoring ${classInfo.name} due to unsatisfied dependency" } } else { - logger.debugNull { "Ignoring ${classInfo.name} due to unsatisfied dependency: ${cause.message}" } + logger.debugNull { "Ignoring ${classInfo.name} due to unsatisfied dependency: ${e.message}" } } } else { throw e @@ -231,10 +232,7 @@ private class ReflectionMetadataScanner private constructor( } } - private fun processMethods( - classInfo: ClassInfo, - kClass: KClass, - ) { + private fun processMethods(classInfo: ClassInfo, kClass: KClass<*>) { for (methodInfo in classInfo.declaredMethodAndConstructorInfo) { //Don't inspect methods with generics if (methodInfo.parameterInfo @@ -242,7 +240,7 @@ private class ReflectionMetadataScanner private constructor( .any { it is TypeVariableSignature || (it is ArrayTypeSignature && it.elementTypeSignature is TypeVariableSignature) } ) continue - val method: Executable = tryGetExecutable(methodInfo) ?: continue + val method: Executable = tryGetExecutable(kClass, methodInfo) ?: continue val nullabilities = getMethodParameterNullabilities(methodInfo, method) methodMetadataMap[method] = MethodMetadata(methodInfo.minLineNum, nullabilities) @@ -252,17 +250,15 @@ private class ReflectionMetadataScanner private constructor( } } - private fun tryGetExecutable(methodInfo: MethodInfo): Executable? { + private fun tryGetExecutable(kClass: KClass<*>, methodInfo: MethodInfo): Executable? { // Ignore methods with missing dependencies (such as parameters from unknown dependencies) try { return when { - methodInfo.isConstructor -> methodInfo.loadClassAndGetConstructor() - else -> methodInfo.loadClassAndGetMethod() + methodInfo.isConstructor -> kClass.java.getConstructor(*methodInfo.getParameterTypes()) + else -> kClass.java.getMethod(methodInfo.name, *methodInfo.getParameterTypes()) } - } catch(e: IllegalArgumentException) { - // ClassGraph wraps exceptions in an IAE - val cause = e.cause - if (cause is ClassNotFoundException || cause is NoClassDefFoundError) { + } catch(e: Throwable) { + if (e is ClassNotFoundException || e is NoClassDefFoundError) { return if (logger.isTraceEnabled()) { logger.traceNull(e) { "Ignoring method due to unsatisfied dependencies in ${methodInfo.shortSignature}" } } else { @@ -274,6 +270,50 @@ private class ReflectionMetadataScanner private constructor( } } + private fun MethodInfo.getParameterTypes(): Array> = parameterInfo.mapToArray { + it.typeSignatureOrTypeDescriptor.loadClass() + } + + private fun TypeSignature.loadClass(): Class<*> { + val parameterType = this.toActualParameterType() + return when (parameterType) { + is ClassRefTypeSignature -> loadClass(parameterType.className) + is BaseTypeSignature -> parameterType.type + is ArrayTypeSignature -> { + val elementType = parameterType.elementTypeSignature.loadClass() + // Create an array of the target number of dimensions, with size zero in each dimension + val array = java.lang.reflect.Array.newInstance(elementType, *IntArray(parameterType.numDimensions)) + array.javaClass + } + else -> error("Unhandled parameter type: $parameterType") + } + } + + private val ClassRefTypeSignature.className: String + get() = classInfo?.name ?: fullyQualifiedClassName + + private fun TypeSignature.toActualParameterType(): TypeSignature { + if (this is TypeVariableSignature) { +// val typeParameter = resolve() +// if (typeParameter.classBound != null) { +// return typeParameter.classBound +// } +// +// if (typeParameter.interfaceBounds != null && typeParameter.interfaceBounds!!.isNotEmpty()) { +// return typeParameter.interfaceBounds!![0] +// } +// +// error("TypeVariableSignature has no bounds") + throwInternal("Methods with generics should have been ignored") + } + + return this + } + + private fun loadClass(name: String): Class<*> { + return Class.forName(name, false, Thread.currentThread().contextClassLoader) + } + private fun getMethodParameterNullabilities(methodInfo: MethodInfo, method: Executable): List { val nullabilities = methodInfo.parameterInfo.dropLast(if (method.isSuspend) 1 else 0).map { parameterInfo -> parameterInfo.annotationInfo.any { it.name.endsWith("Nullable") } @@ -327,4 +367,4 @@ internal val KFunction<*>.lineNumber: Int get() = ReflectionMetadata.instance.getMethodMetadata(this).line internal val KFunction<*>.lineNumberOrNull: Int? - get() = ReflectionMetadata.instance.getMethodMetadataOrNull(this)?.line \ No newline at end of file + get() = ReflectionMetadata.instance.getMethodMetadataOrNull(this)?.line diff --git a/src/test/java/io/github/freya022/botcommands/test/commands/slash/SlashDIJava.java b/src/test/java/io/github/freya022/botcommands/test/commands/slash/SlashDIJava.java index 1558b71d2..de115bb4d 100644 --- a/src/test/java/io/github/freya022/botcommands/test/commands/slash/SlashDIJava.java +++ b/src/test/java/io/github/freya022/botcommands/test/commands/slash/SlashDIJava.java @@ -7,9 +7,9 @@ import io.github.freya022.botcommands.api.commands.application.slash.annotations.JDASlashCommand; import io.github.freya022.botcommands.api.core.db.BlockingDatabase; import io.github.freya022.botcommands.api.core.service.LazyService; +import io.github.freya022.botcommands.api.core.service.annotations.RequiresDefaultInjection; import io.github.freya022.botcommands.api.core.service.annotations.ServiceName; import io.github.freya022.botcommands.internal.core.ReadyListener; -import io.github.freya022.botcommands.internal.core.service.annotations.RequiresDefaultInjection; import io.github.freya022.botcommands.test.services.INamedService; import io.github.freya022.botcommands.test.services.UnusedInterfacedService; import org.jetbrains.annotations.Nullable; diff --git a/src/test/kotlin/io/github/freya022/botcommands/framework/utils/Utils.kt b/src/test/kotlin/io/github/freya022/botcommands/framework/utils/Utils.kt index 34a748aee..56427162c 100644 --- a/src/test/kotlin/io/github/freya022/botcommands/framework/utils/Utils.kt +++ b/src/test/kotlin/io/github/freya022/botcommands/framework/utils/Utils.kt @@ -11,7 +11,7 @@ fun BotCommands.createTest( modals: Boolean = false, appEmojis: Boolean = false, builder: BConfigBuilder.() -> Unit -) = create { +) = create(emptyArray()) { disableExceptionsInDMs = true addClass() diff --git a/src/test/kotlin/io/github/freya022/botcommands/test/Main.kt b/src/test/kotlin/io/github/freya022/botcommands/test/Main.kt index b16c97ac3..61da0f5bd 100644 --- a/src/test/kotlin/io/github/freya022/botcommands/test/Main.kt +++ b/src/test/kotlin/io/github/freya022/botcommands/test/Main.kt @@ -9,7 +9,6 @@ import io.github.oshai.kotlinlogging.KotlinLogging import net.dv8tion.jda.api.interactions.DiscordLocale import java.lang.management.ManagementFactory import kotlin.io.path.absolutePathString -import kotlin.system.exitProcess import kotlin.time.Duration.Companion.milliseconds const val botName = "BC Test" @@ -19,71 +18,66 @@ object Main { @JvmStatic fun main(args: Array) { - try { - System.setProperty(ClassicConstants.CONFIG_FILE_PROPERTY, Environment.logbackConfigPath.absolutePathString()) - logger.info { "Loading logback configuration at ${Environment.logbackConfigPath.absolutePathString()}" } - - // I use hotswap agent to update my code without restarting the bot - // Of course this only supports modifying existing code - // Refer to https://github.com/HotswapProjects/HotswapAgent#readme on how to use hotswap - - // stacktrace-decoroutinator has issues when reloading with hotswap agent - if ("-XX:+AllowEnhancedClassRedefinition" in ManagementFactory.getRuntimeMXBean().inputArguments) { - logger.info { "Skipping stacktrace-decoroutinator as enhanced hotswap is active" } - } else if ("--no-decoroutinator" in args) { - logger.info { "Skipping stacktrace-decoroutinator as --no-decoroutinator is specified" } - } else { - DecoroutinatorJvmApi.install() - } - - BotCommands.create { - disableExceptionsInDMs = true + System.setProperty(ClassicConstants.CONFIG_FILE_PROPERTY, Environment.logbackConfigPath.absolutePathString()) + logger.info { "Loading logback configuration at ${Environment.logbackConfigPath.absolutePathString()}" } + + // I use hotswap agent to update my code without restarting the bot + // Of course this only supports modifying existing code + // Refer to https://github.com/HotswapProjects/HotswapAgent#readme on how to use hotswap + + // stacktrace-decoroutinator has issues when reloading with hotswap agent + if ("-XX:+AllowEnhancedClassRedefinition" in ManagementFactory.getRuntimeMXBean().inputArguments) { + logger.info { "Skipping stacktrace-decoroutinator as enhanced hotswap is active" } + } else if ("--no-decoroutinator" in args) { + logger.info { "Skipping stacktrace-decoroutinator as --no-decoroutinator is specified" } + } else { + DecoroutinatorJvmApi.install() + } - addSearchPath("io.github.freya022.botcommands.test") + BotCommands.create(args) { + disableExceptionsInDMs = true - database { - queryLogThreshold = 250.milliseconds + addSearchPath("io.github.freya022.botcommands.test") - @OptIn(DevConfig::class) - dumpLongTransactions = true - } + database { + queryLogThreshold = 250.milliseconds - localization { - responseBundles += "Test" - } + @OptIn(DevConfig::class) + dumpLongTransactions = true + } - components { - enable = true - } + localization { + responseBundles += "Test" + } - textCommands { - enable = true + components { + enable = true + } - usePingAsPrefix = true - } + textCommands { + enable = true - services { - debug = false - } + usePingAsPrefix = true + } - applicationCommands { - enable = true + services { + debug = false + } - databaseCache { - @OptIn(DevConfig::class) - checkOnline = true - } + applicationCommands { + enable = true - addLocalizations("MyCommands", DiscordLocale.ENGLISH_US, DiscordLocale.ENGLISH_UK, DiscordLocale.FRENCH) + databaseCache { + @OptIn(DevConfig::class) + checkOnline = true } - modals { - enable = true - } + addLocalizations("MyCommands", DiscordLocale.ENGLISH_US, DiscordLocale.ENGLISH_UK, DiscordLocale.FRENCH) + } + + modals { + enable = true } - } catch (e: Exception) { - logger.error(e) { "Could not start the test bot" } - exitProcess(1) } } } diff --git a/src/test/kotlin/io/github/freya022/botcommands/test/commands/slash/SlashDI.kt b/src/test/kotlin/io/github/freya022/botcommands/test/commands/slash/SlashDI.kt index 049b4c967..212f85c69 100644 --- a/src/test/kotlin/io/github/freya022/botcommands/test/commands/slash/SlashDI.kt +++ b/src/test/kotlin/io/github/freya022/botcommands/test/commands/slash/SlashDI.kt @@ -9,9 +9,9 @@ import io.github.freya022.botcommands.api.commands.application.slash.annotations import io.github.freya022.botcommands.api.core.DefaultEmbedSupplier import io.github.freya022.botcommands.api.core.db.BlockingDatabase import io.github.freya022.botcommands.api.core.service.LazyService +import io.github.freya022.botcommands.api.core.service.annotations.RequiresDefaultInjection import io.github.freya022.botcommands.api.core.service.annotations.ServiceName import io.github.freya022.botcommands.internal.core.ReadyListener -import io.github.freya022.botcommands.internal.core.service.annotations.RequiresDefaultInjection import io.github.freya022.botcommands.test.services.INamedService import io.github.freya022.botcommands.test.services.NamedService1 import io.github.freya022.botcommands.test.services.UnusedInterfacedService diff --git a/src/test/kotlin/io/github/freya022/botcommands/test/commands/slash/SlashDynamicTypedResolver.kt b/src/test/kotlin/io/github/freya022/botcommands/test/commands/slash/SlashDynamicTypedResolver.kt index ad04a80d0..2b641783a 100644 --- a/src/test/kotlin/io/github/freya022/botcommands/test/commands/slash/SlashDynamicTypedResolver.kt +++ b/src/test/kotlin/io/github/freya022/botcommands/test/commands/slash/SlashDynamicTypedResolver.kt @@ -7,7 +7,7 @@ import io.github.freya022.botcommands.api.commands.application.ApplicationComman import io.github.freya022.botcommands.api.commands.application.slash.GuildSlashEvent import io.github.freya022.botcommands.api.commands.application.slash.annotations.JDASlashCommand import io.github.freya022.botcommands.api.commands.application.slash.annotations.SlashOption -import io.github.freya022.botcommands.internal.core.service.annotations.RequiresDefaultInjection +import io.github.freya022.botcommands.api.core.service.annotations.RequiresDefaultInjection @Command // As the framework checks if a custom option isn't in reality a service option, diff --git a/src/test/kotlin/io/github/freya022/botcommands/test/resolvers/MapResolver.kt b/src/test/kotlin/io/github/freya022/botcommands/test/resolvers/MapResolver.kt index 8a17fc430..dc4ac14e1 100644 --- a/src/test/kotlin/io/github/freya022/botcommands/test/resolvers/MapResolver.kt +++ b/src/test/kotlin/io/github/freya022/botcommands/test/resolvers/MapResolver.kt @@ -1,13 +1,13 @@ package io.github.freya022.botcommands.test.resolvers import io.github.freya022.botcommands.api.core.options.Option +import io.github.freya022.botcommands.api.core.service.annotations.RequiresDefaultInjection import io.github.freya022.botcommands.api.core.service.annotations.ResolverFactory import io.github.freya022.botcommands.api.core.utils.simpleNestedName import io.github.freya022.botcommands.api.parameters.ParameterResolverFactory import io.github.freya022.botcommands.api.parameters.ResolverRequest import io.github.freya022.botcommands.api.parameters.TypedParameterResolver import io.github.freya022.botcommands.api.parameters.resolvers.ICustomResolver -import io.github.freya022.botcommands.internal.core.service.annotations.RequiresDefaultInjection import net.dv8tion.jda.api.events.Event import kotlin.reflect.KType import kotlin.reflect.typeOf diff --git a/src/test/kotlin/io/github/freya022/botcommands/test/services/FactoryServiceTest.kt b/src/test/kotlin/io/github/freya022/botcommands/test/services/FactoryServiceTest.kt index d1bd1ca67..00358a668 100644 --- a/src/test/kotlin/io/github/freya022/botcommands/test/services/FactoryServiceTest.kt +++ b/src/test/kotlin/io/github/freya022/botcommands/test/services/FactoryServiceTest.kt @@ -1,10 +1,6 @@ package io.github.freya022.botcommands.test.services -import io.github.freya022.botcommands.api.core.service.annotations.BConfiguration -import io.github.freya022.botcommands.api.core.service.annotations.BService -import io.github.freya022.botcommands.api.core.service.annotations.ConditionalService -import io.github.freya022.botcommands.api.core.service.annotations.Dependencies -import io.github.freya022.botcommands.internal.core.service.annotations.RequiresDefaultInjection +import io.github.freya022.botcommands.api.core.service.annotations.* //Can test failure if FactoryServiceTest is not instantiable, by commenting @Dependencies @BService diff --git a/src/test/kotlin/io/github/freya022/botcommands/test/services/NamedService.kt b/src/test/kotlin/io/github/freya022/botcommands/test/services/NamedService.kt index 158940f1c..ef8be5cdf 100644 --- a/src/test/kotlin/io/github/freya022/botcommands/test/services/NamedService.kt +++ b/src/test/kotlin/io/github/freya022/botcommands/test/services/NamedService.kt @@ -2,7 +2,7 @@ package io.github.freya022.botcommands.test.services import io.github.freya022.botcommands.api.core.service.annotations.BService import io.github.freya022.botcommands.api.core.service.annotations.InterfacedService -import io.github.freya022.botcommands.internal.core.service.annotations.RequiresDefaultInjection +import io.github.freya022.botcommands.api.core.service.annotations.RequiresDefaultInjection @InterfacedService(acceptMultiple = true) interface INamedService diff --git a/src/test/kotlin/io/github/freya022/botcommands/test/services/NonUniqueProviderTest.kt b/src/test/kotlin/io/github/freya022/botcommands/test/services/NonUniqueProviderTest.kt index f84cdf6b7..7aaeaf1f3 100644 --- a/src/test/kotlin/io/github/freya022/botcommands/test/services/NonUniqueProviderTest.kt +++ b/src/test/kotlin/io/github/freya022/botcommands/test/services/NonUniqueProviderTest.kt @@ -3,7 +3,7 @@ package io.github.freya022.botcommands.test.services import io.github.freya022.botcommands.api.core.service.annotations.BConfiguration import io.github.freya022.botcommands.api.core.service.annotations.BService import io.github.freya022.botcommands.api.core.service.annotations.Lazy -import io.github.freya022.botcommands.internal.core.service.annotations.RequiresDefaultInjection +import io.github.freya022.botcommands.api.core.service.annotations.RequiresDefaultInjection // Even if this is lazy, this should throw as Service1 has multiple definitions and no name matches @Lazy