Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
b113ef7
Added an internal application start event
freya022 Feb 7, 2026
ee36ca6
Add restarter module
freya022 Feb 7, 2026
cbec51f
Add implementation of RestartClassLoaderAdapterFactory
freya022 Feb 10, 2026
97f8907
test-bot: Use restarter module
freya022 Feb 7, 2026
c0c585a
Remove unused code
freya022 Feb 11, 2026
a7bb8e3
Rename config file
freya022 Feb 11, 2026
d500a0e
Add README.md
freya022 Feb 11, 2026
719c044
Clear cache of method accessors on restart
freya022 Feb 12, 2026
6be361d
Update config
freya022 Feb 15, 2026
4a3c762
Add missing badge
freya022 Feb 15, 2026
d103d09
Reset AppEmojisLoader on (re)start
freya022 Feb 17, 2026
d87363c
Add admonition about the feature being development only
freya022 Mar 1, 2026
b3f3ba9
Use same docs model for opt-in annotation
freya022 Mar 1, 2026
64358a1
Update comments
freya022 Mar 1, 2026
ee2f9a9
Simplify `lateinit` `Future` with a pre-completed future
freya022 Mar 2, 2026
a8e1418
Log invalid key resets at debug
freya022 Mar 2, 2026
91218ab
Cleanup
freya022 Mar 2, 2026
f183e0c
Update README.md
freya022 Mar 2, 2026
30e2d7f
Expose `ImmediateRestartException`
freya022 Mar 2, 2026
90e513b
test-bot: Rethrow `ImmediateRestartException`
freya022 Mar 2, 2026
9ae7dbb
Update README.md
freya022 Mar 2, 2026
0bf33b5
Cleanup
freya022 Mar 2, 2026
d15b1f0
Several improvements
freya022 Mar 2, 2026
7a3bebf
Remove unused JDA dependency
freya022 Mar 2, 2026
3b4cdf2
Move `@ExperimentalRestartApi` to package
freya022 Mar 2, 2026
e796b24
Private `ImmediateRestartException` ctor
freya022 Mar 2, 2026
56d1d58
Add visibility modifiers
freya022 Mar 2, 2026
f781870
Update README
freya022 Mar 2, 2026
5a8c7bc
Update README
freya022 Mar 2, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import kotlin.reflect.KFunction

internal object MethodAccessorFactoryProvider {

private lateinit var accessorFactory: MethodAccessorFactory
private lateinit var accessorFactory: CachingMethodAccessorFactory
private val staticAccessors: MutableMap<KFunction<*>, MethodAccessor<*>> = hashMapOf()

internal fun getAccessorFactory(): MethodAccessorFactory {
Expand All @@ -26,6 +26,12 @@ internal object MethodAccessorFactoryProvider {
return accessorFactory
}

internal fun clearCache() {
if (::accessorFactory.isInitialized) {
accessorFactory.clearCache()
}
}

@OptIn(ExperimentalMethodAccessorsApi::class)
private fun loadAccessorFactory(): MethodAccessorFactory {
if (MethodAccessorsConfig.preferClassFileAccessors) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import io.github.freya022.botcommands.api.core.events.PreLoadEvent
import io.github.freya022.botcommands.api.core.objectLogger
import io.github.freya022.botcommands.api.core.service.getService
import io.github.freya022.botcommands.internal.core.BContextImpl
import io.github.freya022.botcommands.internal.core.method.accessors.MethodAccessorFactoryProvider
import io.github.freya022.botcommands.internal.emojis.AppEmojisLoader
import io.github.freya022.botcommands.internal.utils.ReflectionMetadata
import kotlinx.coroutines.runBlocking
import kotlin.time.DurationUnit
Expand All @@ -18,6 +20,9 @@ abstract class AbstractBotCommandsBootstrap(protected val config: BConfig) : Bot
protected val logger = objectLogger()

protected fun init() {
MethodAccessorFactoryProvider.clearCache()
AppEmojisLoader.clear()

measure("Scanned reflection metadata") {
ReflectionMetadata.runScan(config, this)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import io.github.freya022.botcommands.api.core.service.ClassGraphProcessor
import io.github.freya022.botcommands.api.core.service.ServiceContainer
import io.github.freya022.botcommands.api.core.utils.findAnnotationRecursive
import io.github.freya022.botcommands.api.emojis.annotations.AppEmojiContainer
import org.jetbrains.annotations.TestOnly
import kotlin.reflect.KClass

internal object AppEmojiContainerProcessor : ClassGraphProcessor {
Expand All @@ -23,8 +22,7 @@ internal object AppEmojiContainerProcessor : ClassGraphProcessor {
}
}

@TestOnly
internal fun clear() {
emojiClasses.clear()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import net.dv8tion.jda.api.JDA
import net.dv8tion.jda.api.entities.Icon
import net.dv8tion.jda.api.entities.emoji.ApplicationEmoji
import net.dv8tion.jda.internal.utils.Checks
import org.jetbrains.annotations.TestOnly
import kotlin.math.abs
import kotlin.reflect.KProperty
import kotlin.reflect.full.declaredMemberProperties
Expand Down Expand Up @@ -235,7 +234,6 @@ internal class AppEmojisLoader internal constructor(
private val toLoad = arrayListOf<LoadRequest>()
private val loadedEmojis = hashMapOf<String, ApplicationEmoji>()

@TestOnly
internal fun clear() {
loaded = false
toLoadEmojiNames.clear()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ class CachingMethodAccessorFactory(private val delegate: MethodAccessorFactory)
private val cache = WeakHashMap<Executable, MethodAccessor<*>>()
private val lock = ReentrantLock()

fun clearCache() {
cache.clear()
}

override fun <R> create(
instance: Any?,
function: KFunction<R>,
Expand Down
82 changes: 82 additions & 0 deletions BotCommands-restarter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
[bc-module-maven-central-shield]: https://img.shields.io/maven-central/v/io.github.freya022/BotCommands-restarter?label=Maven%20central&logo=apachemaven&versionPrefix=3
[bc-module-maven-central-link]: https://central.sonatype.com/artifact/io.github.freya022/BotCommands-restarter

# BotCommands module - Hot restarter
When you build changes of your code, this modules restarts your app automatically, in the same JVM,
leading to much faster restarts, as it doesn't need to recompile most of the code.

> [!WARNING]
> If you are using Spring, use [`spring-boot-devtools`](https://docs.spring.io/spring-boot/reference/using/devtools.html) instead.

## Installing
[![BotCommands-restarter on maven central][bc-module-maven-central-shield] ][bc-module-maven-central-link]

Comment thread
freya022 marked this conversation as resolved.
### Maven
```xml
<dependencies>
<dependency>
<groupId>io.github.freya022</groupId>
<artifactId>BotCommands-restarter</artifactId>
<version>VERSION</version>
</dependency>
</dependencies>
```

### Gradle
```gradle
repositories {
mavenCentral()
}

dependencies {
implementation("io.github.freya022:BotCommands-restarter:VERSION")
}
```

### Snapshots

To use the latest, unreleased changes, see [SNAPSHOTS.md](../SNAPSHOTS.md).

## Usage
You can enable the feature by doing so, after which, every build will restart your application.

> [!NOTE]
> You should minimize the amount of code executed before calling `BotCommandsRestarter.initialize`,
> as it will run twice on startup, then everytime it is restarted.

> [!IMPORTANT]
> You must only use this feature during development, here are a few ways to do so:
> - Using a program argument like `--dev` then reading it from `args`
> - Using a configuration file with a `IS_DEV` property
> - Using an environment variable

### Kotlin
```kotlin
fun main(args: Array<out String>) {
// You should enable this only during development
@OptIn(ExperimentalRestartApi::class)
BotCommandsRestarter.initialize(args) {
// Optional configuration
}

// ...
BotCommands.create {
// ...
}
}
```

### Java
```java
void main(String[] args) {
// You should enable this only during development
BotCommandsRestarter.initialize(args, builder -> {
// Optional configuration
});

// ...
BotCommands.create(config -> {
// ...
});
}
```
31 changes: 31 additions & 0 deletions BotCommands-restarter/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import dev.freya02.botcommands.plugins.configureJarArtifact

plugins {
id("repositories-conventions")
id("kotlin-conventions")
id("publish-conventions")
id("dokka-conventions")
}

dependencies {
api(projects.botCommandsCore)

// Logging
implementation(libs.kotlin.logging)
}

kotlin {
compilerOptions {
freeCompilerArgs.addAll(
"-opt-in=dev.freya02.botcommands.restarter.api.annotations.ExperimentalRestartApi",
)
}
}

publishedProjectEnvironment {
configureJarArtifact(
artifactId = "BotCommands-restarter",
description = "Enables restarting your bot on the same JVM during development.",
url = "https://github.com/freya022/BotCommands/tree/3.X/BotCommands-restarter",
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package dev.freya02.botcommands.restarter.api

import dev.freya02.botcommands.restarter.api.BotCommandsRestarter.initialize
import dev.freya02.botcommands.restarter.api.annotations.ExperimentalRestartApi
import dev.freya02.botcommands.restarter.api.config.RestarterConfigBuilder
import dev.freya02.botcommands.restarter.api.exceptions.ImmediateRestartException
import dev.freya02.botcommands.restarter.internal.Restarter
import io.github.freya022.botcommands.api.ReceiverConsumer

/**
* Entry point for the "hot restart" feature.
*
* @see initialize
*/
@ExperimentalRestartApi
object BotCommandsRestarter {

/**
* Enables hot restarting for this application. All changes to your code will restart the application.
*
* This method will intentionally throw [ImmediateRestartException] on the first run,
* if it is caught, you must rethrow it.
*
* It is recommended to run this function as soon as possible to avoid running the same code twice on startup.
*
* **Note:** If you configure your logger programmatically, it must be done before calling this function.
*
* @param args The program arguments, they will be passed back to the main function upon restarting
* @param configBuilder To further configure the feature, this will only run once per application
*/
@JvmStatic
@JvmOverloads
fun initialize(args: Array<out String>, configBuilder: ReceiverConsumer<RestarterConfigBuilder> = {}) {
if (!Restarter.isInitialized) {
// 1st restart
val config = RestarterConfigBuilder.create(args)
.apply(configBuilder)
.build()
Restarter.initialize(config)
}

// After 1st restart
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package dev.freya02.botcommands.restarter.api.annotations

import kotlin.annotation.AnnotationTarget.*

/**
* Opt-in marker annotation for "hot restart" APIs that are considered experimental and are not subject to compatibility guarantees:
* The behavior of such API may be changed or the API may be removed completely in any further release.
*
* Please create an issue or join the Discord server if you encounter a problem or want to submit feedback.
*
* Any usage of a declaration annotated with `@ExperimentalRestartApi` must be accepted either by
* annotating that usage with the [@OptIn][OptIn] annotation, e.g. `@OptIn(ExperimentalRestartApi::class)`,
* or by using the compiler argument `-opt-in=dev.freya02.botcommands.restarter.api.ExperimentalRestartApi`.
*/
@RequiresOptIn(level = RequiresOptIn.Level.ERROR)
@Retention(AnnotationRetention.BINARY)
@Target(
CLASS,
ANNOTATION_CLASS,
PROPERTY,
FIELD,
LOCAL_VARIABLE,
VALUE_PARAMETER,
CONSTRUCTOR,
FUNCTION,
PROPERTY_GETTER,
PROPERTY_SETTER,
TYPEALIAS
)
@MustBeDocumented
annotation class ExperimentalRestartApi
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package dev.freya02.botcommands.restarter.api.config

import dev.freya02.botcommands.restarter.api.BotCommandsRestarter
import dev.freya02.botcommands.restarter.api.annotations.ExperimentalRestartApi
import io.github.freya022.botcommands.internal.core.config.ConfigDSL
import java.time.Duration as JavaDuration
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaDuration
import kotlin.time.toKotlinDuration

@ExperimentalRestartApi
interface RestarterConfig {

/**
* The program arguments passed to the main function upon restarting.
*/
val startArgs: List<String>

/**
* The time to wait before assuming all changes were compiled,
* so the application can be restarted with the new changes.
*
* Default: 1 second
*/
val restartDelay: Duration

/**
* Returns the time to wait before assuming all changes were compiled,
* so the application can be restarted with the new changes.
*
* Default: 1 second
*/
fun getRestartDelay(): JavaDuration = restartDelay.toJavaDuration()
}

/**
* Builder of [RestarterConfig].
*
* @see [BotCommandsRestarter.initialize]
*/
@ConfigDSL
@ExperimentalRestartApi
class RestarterConfigBuilder private constructor(
override val startArgs: List<String>,
) : RestarterConfig {

override var restartDelay: Duration = 1.seconds

/**
* Sets the time to wait before assuming all changes were compiled,
* so the application can be restarted with the new changes.
*
* Default: 1 second
*/
fun setRestartDelay(delay: JavaDuration): RestarterConfigBuilder {
this.restartDelay = delay.toKotlinDuration()
return this
}

@JvmSynthetic
internal fun build(): RestarterConfig = object : RestarterConfig {
override val startArgs = this@RestarterConfigBuilder.startArgs
override val restartDelay = this@RestarterConfigBuilder.restartDelay
}

internal companion object {

@JvmSynthetic
internal fun create(args: Array<out String>): RestarterConfigBuilder {
return RestarterConfigBuilder(args.toList())
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package dev.freya02.botcommands.restarter.api.exceptions

import java.lang.reflect.InvocationTargetException

/**
* Exception thrown intentionally after enabling the hot restart feature.
*
* This exception must propagate to the main method, and must not be caught, if it is, you must rethrow it.
*/
class ImmediateRestartException private constructor() : RuntimeException("Dummy exception to stop the execution of the first main thread") {

internal companion object {
@JvmSynthetic
internal fun throwAndHandle(): Nothing {
val currentThread = Thread.currentThread()
currentThread.uncaughtExceptionHandler = ExpectedRestartExceptionHandler(currentThread.uncaughtExceptionHandler)
throw ImmediateRestartException()
}
}

private class ExpectedRestartExceptionHandler(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()
}
}
}
}
Loading