Skip to content

Commit 41aaa0f

Browse files
This adds a new hot experimental hot reload feature to Misk, that will
run `gradle compileKotlin --continuous` in the background, and when classes are changed it will discard the current ClassLoader and restart Misk. To use the main method should call `runDevApplication`, and pass in a method that creates the application. This functionality is experimental, and will likely see significant change over the coming weeks. GitOrigin-RevId: 1dadbd4b25057a98ce65495c9fb7df1661262a8d
1 parent a5d3188 commit 41aaa0f

File tree

11 files changed

+360
-71
lines changed

11 files changed

+360
-71
lines changed

misk-tailwind/src/main/kotlin/misk/tailwind/TailwindHtmlLayout.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@ import kotlinx.html.script
1010
import kotlinx.html.title
1111
import misk.turbo.addHotwireHeadImports
1212

13+
14+
internal class DevMode {
15+
companion object {
16+
val devMode by lazy {
17+
System.getProperty("misk.dev.running") == "true"
18+
}
19+
}
20+
}
21+
1322
fun TagConsumer<*>.TailwindHtmlLayout(appRoot: String, title: String, playCdn: Boolean = false, appCssPath: String? = null, headBlock: TagConsumer<*>.() -> Unit = {}, bodyBlock: TagConsumer<*>.() -> Unit) {
1423
html {
1524
attributes["class"] = "h-full bg-white"
@@ -46,6 +55,12 @@ fun TagConsumer<*>.TailwindHtmlLayout(appRoot: String, title: String, playCdn: B
4655
rel = "stylesheet"
4756
}
4857
title(title)
58+
if (DevMode.devMode) {
59+
script {
60+
type = "module"
61+
src = "/static/js/refresh_dev.js"
62+
}
63+
}
4964

5065
addHotwireHeadImports(appRoot)
5166
headBlock()
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Endpoint polling and auto-refresh script. This is only used with live reload enabled.
2+
// When the liveness endpoint is not reachable, it will wait for the server to come back online and refresh the page.
3+
(function() {
4+
// Configuration
5+
const POLL_ENDPOINT = '/_liveness'; // Change this to your backend endpoint
6+
const POLL_INTERVAL = 300;
7+
8+
let isServerDown = false;
9+
let pollInterval;
10+
11+
// Function to check if the server is alive
12+
async function checkServerStatus() {
13+
try {
14+
const response = await fetch(POLL_ENDPOINT, {
15+
method: 'GET',
16+
cache: 'no-cache',
17+
// Short timeout to quickly detect connection issues
18+
signal: AbortSignal.timeout(5000)
19+
});
20+
21+
// If we get here, the server responded
22+
if (isServerDown) {
23+
// Server was down but is now back up
24+
console.log('Server is back online! Refreshing page...');
25+
clearInterval(pollInterval);
26+
setTimeout(() => {
27+
window.location.reload();
28+
}, 500); // Small delay before refresh
29+
}
30+
31+
// Reset retry count on successful connection
32+
retryCount = 0;
33+
34+
} catch (error) {
35+
// Check if it's a connection error (server is down)
36+
if (error.name === 'TypeError' && error.message.includes('Failed to fetch')) {
37+
if (!isServerDown) {
38+
console.log('Server connection lost. Waiting for it to come back...');
39+
isServerDown = true;
40+
}
41+
}
42+
}
43+
}
44+
45+
// Start polling
46+
console.log('Starting server status monitoring...');
47+
pollInterval = setInterval(checkServerStatus, POLL_INTERVAL);
48+
49+
// Also check immediately
50+
checkServerStatus();
51+
})();

misk/api/misk.api

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -795,6 +795,11 @@ public final class misk/concurrent/ExplicitReleaseDelayQueue : java/util/concurr
795795
public fun toArray ([Ljava/lang/Object;)[Ljava/lang/Object;
796796
}
797797

798+
public final class misk/dev/DevApplicationKt {
799+
public static final fun isRunningDevApplication ()Z
800+
public static final fun runDevApplication (Lkotlin/reflect/KFunction;)V
801+
}
802+
798803
public final class misk/environment/DeploymentModule : misk/inject/KAbstractModule {
799804
public fun <init> ()V
800805
public fun <init> (Lwisp/deployment/Deployment;)V

misk/src/main/kotlin/misk/MiskApplication.kt

Lines changed: 64 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import misk.inject.getInstance
1212
import misk.web.jetty.JettyHealthService
1313
import misk.web.jetty.JettyService
1414
import wisp.logging.getLogger
15+
import java.io.Closeable
16+
import java.util.concurrent.TimeUnit
17+
import java.util.concurrent.TimeoutException
1518
import kotlin.concurrent.thread
1619
import kotlin.system.measureTimeMillis
1720
import kotlin.time.Duration.Companion.milliseconds
@@ -60,7 +63,6 @@ class MiskApplication private constructor(
6063
*
6164
* If no command line arguments are specified, the service starts and blocks until terminated.
6265
*/
63-
@VisibleForTesting
6466
internal fun doRun(args: Array<String>) {
6567
if (args.isEmpty()) {
6668
startServiceAndAwaitTermination()
@@ -79,7 +81,7 @@ class MiskApplication private constructor(
7981
binder().requireAtInjectOnConstructors()
8082
}
8183
},
82-
*command.modules.toTypedArray()
84+
*command.modules.toTypedArray(),
8385
)
8486

8587
injector.injectMembers(command)
@@ -101,18 +103,36 @@ class MiskApplication private constructor(
101103
* exiting the process.
102104
*/
103105
@VisibleForTesting
104-
internal lateinit var shutdownHook : Thread
106+
internal lateinit var shutdownHook: Thread
105107

106108
/**
107109
* Provides internal testing the ability to get instances this used by the application.
108110
*/
109111
@VisibleForTesting
110-
internal lateinit var injector : Injector
112+
internal lateinit var injector: Injector
111113

112-
private fun startServiceAndAwaitTermination() {
114+
internal fun start(): RunningMiskApplication {
113115
log.info { "creating application injector" }
114116
injector = injectorGenerator()
115117
val serviceManager = injector.getInstance<ServiceManager>()
118+
119+
120+
// We manage JettyHealthService outside ServiceManager because it must start and
121+
// shutdown last to keep the container alive via liveness checks.
122+
val jettyHealthService: JettyHealthService
123+
measureTimeMillis {
124+
log.info { "starting services" }
125+
serviceManager.startAsync()
126+
serviceManager.awaitHealthy()
127+
128+
// Start Health Service Last to ensure any dependencies are started.
129+
jettyHealthService = injector.getInstance<JettyHealthService>()
130+
jettyHealthService.startAsync()
131+
jettyHealthService.awaitRunning()
132+
}.also {
133+
log.info { "all services started successfully in ${it.milliseconds}" }
134+
}
135+
116136
shutdownHook = thread(start = false) {
117137
measureTimeMillis {
118138
log.info { "received a shutdown hook! performing an orderly shutdown" }
@@ -135,29 +155,40 @@ class MiskApplication private constructor(
135155
log.info { "orderly shutdown complete in ${it.milliseconds}" }
136156
}
137157
}
158+
val shutdown: RunningMiskApplication = object : RunningMiskApplication {
159+
override fun stop() {
160+
shutdownHook.start()
161+
}
138162

139-
Runtime.getRuntime().addShutdownHook(shutdownHook)
140-
141-
// We manage JettyHealthService outside ServiceManager because it must start and
142-
// shutdown last to keep the container alive via liveness checks.
163+
override fun awaitTerminated() {
164+
serviceManager.awaitStopped()
165+
jettyHealthService.awaitTerminated()
166+
log.info { "all services stopped" }
167+
}
143168

144-
val jettyHealthService: JettyHealthService
145-
measureTimeMillis {
146-
log.info { "starting services" }
147-
serviceManager.startAsync()
148-
serviceManager.awaitHealthy()
169+
override fun awaitTerminated(time : Long, timeUnit: TimeUnit) : Boolean{
170+
val deadline : Long = timeUnit.toMillis(time) + System.currentTimeMillis()
171+
try {
172+
serviceManager.awaitStopped(time, timeUnit)
173+
jettyHealthService.awaitTerminated(deadline - System.currentTimeMillis(), TimeUnit.MILLISECONDS)
174+
} catch (_ : TimeoutException) {
175+
return false
176+
}
177+
log.info { "all services stopped" }
178+
return true
179+
}
149180

150-
// Start Health Service Last to ensure any dependencies are started.
151-
jettyHealthService = injector.getInstance<JettyHealthService>()
152-
jettyHealthService.startAsync()
153-
jettyHealthService.awaitRunning()
154-
}.also {
155-
log.info { "all services started successfully in ${it.milliseconds}" }
181+
override fun app(): MiskApplication {
182+
return this@MiskApplication
183+
}
156184
}
185+
return shutdown
186+
}
157187

158-
serviceManager.awaitStopped()
159-
jettyHealthService.awaitTerminated()
160-
log.info { "all services stopped" }
188+
private fun startServiceAndAwaitTermination() {
189+
val app = start()
190+
Runtime.getRuntime().addShutdownHook(shutdownHook)
191+
app.awaitTerminated()
161192
}
162193

163194
private companion object {
@@ -166,3 +197,13 @@ class MiskApplication private constructor(
166197

167198
internal class CliException(message: String) : RuntimeException(message)
168199
}
200+
201+
202+
internal interface RunningMiskApplication {
203+
fun stop()
204+
205+
fun awaitTerminated()
206+
207+
fun app() : MiskApplication
208+
fun awaitTerminated(i: Long, seconds: TimeUnit): Boolean
209+
}

misk/src/main/kotlin/misk/client/GrpcClientProvider.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ internal class GrpcClientProvider<T : Service, G : T>(
128128

129129
return kclass.cast(
130130
Proxy.newProxyInstance(
131-
ClassLoader.getSystemClassLoader(),
131+
Thread.currentThread().contextClassLoader,
132132
arrayOf(kclass.java),
133133
invocationHandler
134134
)

misk/src/main/kotlin/misk/client/TypedHttpClientModule.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ class TypedClientFactory @Inject constructor() {
235235

236236
return kclass.cast(
237237
Proxy.newProxyInstance(
238-
ClassLoader.getSystemClassLoader(),
238+
kclass.java.classLoader,
239239
arrayOf(kclass.java),
240240
invocationHandler
241241
)
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package misk.dev
2+
3+
import misk.MiskApplication
4+
import misk.RunningMiskApplication
5+
import misk.web.actions.javaMethod
6+
import java.lang.reflect.Method
7+
import kotlin.reflect.KFunction
8+
9+
internal class DevApplicationState {
10+
companion object {
11+
@Volatile
12+
var isRunning = false
13+
}
14+
}
15+
16+
@misk.annotation.ExperimentalMiskApi
17+
fun isRunningDevApplication(): Boolean {
18+
return DevApplicationState.isRunning
19+
}
20+
21+
@misk.annotation.ExperimentalMiskApi
22+
fun runDevApplication(modules : KFunction<MiskApplication>) {
23+
DevApplicationState.isRunning = true
24+
System.setProperty("misk.dev.running", "true") // Some code may not depend on misk-core but still need to know about dev mode
25+
val lock = Object()
26+
var restart = false
27+
28+
runGradleAsyncCompile({
29+
synchronized(lock) {
30+
restart = true
31+
lock.notifyAll()
32+
}
33+
})
34+
val parent = Thread.currentThread().contextClassLoader
35+
while (true) {
36+
restart = false
37+
val cl = DevClassLoader(parent)
38+
var running: RunningMiskApplication? = null
39+
try {
40+
Thread.currentThread().contextClassLoader = cl
41+
var javaMethod : Method? = null
42+
try {
43+
javaMethod = modules.javaMethod
44+
?: throw IllegalArgumentException("You cannot pass a lambda to runDevApplication, you must use a method reference")
45+
} catch (e : ClassNotFoundException) {
46+
throw java.lang.RuntimeException("Unable to init live reload, do you have kotlin-reflect as a dependency?",e)
47+
}
48+
val cls = cl.loadClass(javaMethod.declaringClass.name)!!
49+
50+
val app: MiskApplication = cls.getDeclaredMethod(javaMethod.name).invoke(cls) as MiskApplication
51+
running = app.start()
52+
} catch (e: Exception) {
53+
if (e.cause is ClassNotFoundException || e.cause is NoClassDefFoundError) {
54+
Thread.sleep(100)
55+
continue
56+
}
57+
e.printStackTrace()
58+
}
59+
while (!restart) {
60+
synchronized(lock) {
61+
lock.wait()
62+
}
63+
}
64+
running?.stop()
65+
66+
}
67+
68+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package misk.dev
2+
3+
import java.io.File
4+
import java.util.concurrent.ConcurrentHashMap
5+
import java.util.concurrent.ConcurrentSkipListSet
6+
7+
/**
8+
* A class loader that allows for hot reloads. If changes are detected in underlying class files this class loader
9+
* can be discarded and a new instance created.
10+
*/
11+
internal class DevClassLoader(parent: ClassLoader) : ClassLoader(parent) {
12+
13+
// Map from class name to last modified time
14+
private val classFiles: MutableMap<String, Long> = ConcurrentHashMap()
15+
private val classRoots: MutableSet<String> = ConcurrentSkipListSet()
16+
17+
companion object {
18+
init {
19+
registerAsParallelCapable()
20+
}
21+
}
22+
23+
override fun loadClass(className: String, resolve: Boolean): Class<*>? {
24+
val existing = findLoadedClass(className)
25+
if (existing != null) return existing
26+
val classPath = className.replace('.', '/') + ".class"
27+
val uri = parent.getResource(classPath)
28+
if (uri == null) {
29+
return super.loadClass(className, resolve)
30+
}
31+
if (uri.protocol != "file") {
32+
return super.loadClass(className, resolve)
33+
}
34+
35+
val file = File(uri.toURI())
36+
val root = file.absolutePath.substringBeforeLast(classPath)
37+
synchronized(classRoots) {
38+
// We want to scan for all class files eagerly
39+
// If there is a problem on startup they might not be loaded
40+
// And we still want to restart if they are changed
41+
if (classRoots.add(root)) {
42+
scanForClasses(root)
43+
}
44+
}
45+
46+
val data = file.readBytes()
47+
val ret = defineClass(className, data, 0, data.size)
48+
49+
classFiles[file.absolutePath] = file.lastModified()
50+
return ret
51+
}
52+
53+
private fun scanForClasses(root: String) {
54+
val rootDir = File(root)
55+
if (!rootDir.exists() || !rootDir.isDirectory) return
56+
57+
rootDir.walkTopDown().forEach { file ->
58+
if (file.isFile && file.extension == "class") {
59+
classFiles[file.absolutePath] = file.lastModified()
60+
}
61+
}
62+
}
63+
}

0 commit comments

Comments
 (0)