Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
7d37cc9
Add configs
freya022 May 24, 2025
0f00566
Start adding hot reloading
freya022 May 24, 2025
d706334
Listen to file changes using the NIO WatchService
freya022 May 24, 2025
f0a2255
Move path walkers to a util file
freya022 May 24, 2025
01b8643
Start adding restart listeners, refactor
freya022 May 25, 2025
2833759
Moved restarter to module, daemon internal coroutine dispatchers
freya022 May 28, 2025
a1ff53b
notes
freya022 May 30, 2025
b3d28e0
Move RequiresDefaultInjection to API
freya022 May 31, 2025
204b745
Update names
freya022 May 31, 2025
a9f23d4
Move main args to BConfig through entry point, leave feature enabled
freya022 May 31, 2025
639a4ad
Use service-loaded configurer to override ClassGraph's class loader
freya022 May 31, 2025
9ae0b97
Remove restart test files
freya022 May 31, 2025
6db585d
Remove try/catch in test file
freya022 May 31, 2025
892a34e
Remove BConfig#beforeStart
freya022 May 31, 2025
febb1a2
tests: Add back stacktrace-decoroutinator
freya022 May 31, 2025
58e8868
Add notes
freya022 May 31, 2025
baf8c08
Fix name of `JDAConfiguration.shutdownTimeout` replacement property
freya022 May 31, 2025
234f108
Add BRestartConfig#cacheKey
freya022 Jun 3, 2025
8858302
Add BContext#restartConfig
freya022 Jun 3, 2025
e7fded2
Shutdown the bot immediately after receiving a ContextClosedEvent
freya022 Jun 5, 2025
7f33ff7
Remove ClassGraphConfigurer
freya022 Jun 5, 2025
42cfb1a
Don't set a shutdown hook when using Spring
freya022 Jun 5, 2025
cbc4ca7
Shutdown executors after all shards are shut down
freya022 Jun 5, 2025
740c876
Replace `Duration.INFINITE` by `ZERO` when awaiting JDA termination
freya022 Jun 5, 2025
3b523ae
Check for any JDA status beyond `CONNECTING_TO_WEBSOCKET`
freya022 Jun 5, 2025
ac3b285
Use an impossible deadline when the timeout is negative or infinite
freya022 Jun 6, 2025
3bd7689
Don't use shutdown() in shutdownNow()
freya022 Jun 6, 2025
09883e7
Run BotOwnersImpl#onInjectedJDA async
freya022 Jun 6, 2025
d46deeb
Move conditional shutdown hook to a service
freya022 Jun 22, 2025
83fa419
Port BotCommands-Restart (repo)
freya022 Jun 22, 2025
b58297e
Rename DefaultShutdownHook to BCShutdownHook
freya022 Jun 26, 2025
1c9864e
Ignore IllegalStateException from Runtime#removeShutdownHook
freya022 Jun 26, 2025
b4a5694
Restarter: Add properties to exclude directories from restarter class…
freya022 Jun 26, 2025
79912b9
Restarter: Handle exception returned by Restarter#restart
freya022 Jun 27, 2025
5744943
Restarter: Await for an instance to attach before scheduling a restart
freya022 Jun 27, 2025
d640484
Restarter: Register on PostLoadEvent
freya022 Jun 27, 2025
1ad972c
Get BContext in class constructor for SpringJDAShutdownHandler
freya022 Jun 27, 2025
eed7a8b
Remove BConfig#shutdownTimeout
freya022 Jun 27, 2025
a74d850
Mark JDAConfiguration.DevTools#shutdownTimeout for removal
freya022 Jun 27, 2025
34769b0
JDA cache: Add test bot
freya022 Jun 29, 2025
430690d
Notes
freya022 Jun 30, 2025
674a25c
Add ability to load agent at runtime
freya022 Jun 30, 2025
fbad2cb
Rename module to BotCommands-jda-keepalive
freya022 Jun 30, 2025
4574514
Remove Java test class
freya022 Jun 30, 2025
949c41d
Load classes and executables using the current thread's `contextClass…
freya022 Jul 1, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions BotCommands-jda-keepalive/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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<Test> {
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}")
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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<Class<*>>) {
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()
}
}
Original file line number Diff line number Diff line change
@@ -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<GenericEvent> = 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<Any?> {
lock.withLock {
val delegate = delegate ?: error("Should not happen, implement a listener queue if necessary")
return delegate.registeredListeners
}
}
}
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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<String> = hashSetOf()

var hasUnsupportedValues = false
private set

private val builderValues: MutableMap<ValueType, Any?> = 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<CacheFlag>()
}

@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<CacheFlag>) += enumSetOf(first, *others)
}

@DynamicCall
@Suppress("UNCHECKED_CAST")
fun enableCache(flags: Collection<CacheFlag>) {
(builderValues[ValueType.CACHE_FLAGS] as EnumSet<CacheFlag>) += flags
}

@DynamicCall
@Suppress("UNCHECKED_CAST")
fun disableCache(first: CacheFlag, vararg others: CacheFlag) {
(builderValues[ValueType.CACHE_FLAGS] as EnumSet<CacheFlag>) -= enumSetOf(first, *others)
}

@DynamicCall
@Suppress("UNCHECKED_CAST")
fun disableCache(flags: Collection<CacheFlag>) {
(builderValues[ValueType.CACHE_FLAGS] as EnumSet<CacheFlag>) -= 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,
}
}
Loading