Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions examples/nucleus-demo/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ val nativePackageVersion = releaseVersion.substringBefore("-")

nucleus.application {
mainClass = "com.example.demo.MainKt"
nucleusOptimization = true

buildTypes {
release {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package dev.nucleusframework.application.internal

import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import dev.nucleusframework.application.NucleusWindow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.IdentityHashMap
import java.util.logging.Logger

/**
* Runtime side of the `nucleusOptimization { idleGc }` knob.
* Keep the property name in sync with the plugin's `NUCLEUS_IDLE_GC_PROPERTY`.
*/
internal object NucleusOptimization {
const val PROPERTY: String = "nucleus.optimization.idleGc"

val isEnabled: Boolean
get() = System.getProperty(PROPERTY) == "true"
}

/**
* Collects focus / minimized flows from every decorated window and dialog and
* runs [System.gc] according to [IdleGcController].
*/
internal object IdleGc {
private val logger = Logger.getLogger(IdleGc::class.java.name)
private val controller = IdleGcController()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val jobsLock = Any()
private val jobs = IdentityHashMap<NucleusWindow, Job>()
private val applyLock = Any()
private var debounceJob: Job? = null

fun attach(window: NucleusWindow) {
if (!NucleusOptimization.isEnabled) return
synchronized(jobsLock) {
if (window in jobs) return
controller.register(window, window.focusFlow.value, window.minimizedFlow.value)
jobs[window] =
scope.launch {
launch { window.focusFlow.collect { handle(window) } }
launch { window.minimizedFlow.collect { handle(window) } }
}
}
}

fun detach(window: NucleusWindow) {
val cmd =
synchronized(jobsLock) {
jobs.remove(window)?.cancel()
controller.unregister(window)
}
apply(cmd)
}

private fun handle(window: NucleusWindow) {
apply(controller.update(window, window.focusFlow.value, window.minimizedFlow.value))
}

private fun apply(cmd: IdleGcCommand) {
val runNow =
synchronized(applyLock) {
when (cmd) {
IdleGcCommand.NoChange -> false
IdleGcCommand.Cancel -> {
cancelDebounce()
false
}
IdleGcCommand.CollectNow -> {
cancelDebounce()
true
}
IdleGcCommand.Debounce -> {
scheduleDebounce()
false
}
}
}
if (runNow) runGc()
}

private fun cancelDebounce() {
debounceJob?.cancel()
debounceJob = null
}

private fun scheduleDebounce() {
cancelDebounce()
debounceJob =
scope.launch {
delay(IdleGcController.UNFOCUS_DELAY_MS)
if (controller.shouldRunDeferredGc()) {
runGc()
}
}
}

private fun runGc() {
logger.fine("Idle GC")
@Suppress("ExplicitGarbageCollectionCall")
System.gc()
}
}

@Composable
internal fun ObserveIdleGc(window: NucleusWindow) {
if (!NucleusOptimization.isEnabled) return
DisposableEffect(window) {
IdleGc.attach(window)
onDispose { IdleGc.detach(window) }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package dev.nucleusframework.application.internal

/**
* Decides when idle GC should run for the `nucleusOptimization` pack.
*
* Serial is stop-the-world, so a collection is only requested when no tracked
* window is still focused and visible. A minimized window collects immediately;
* a mere focus loss waits [UNFOCUS_DELAY_MS] so alt-tab / click-away that
* comes back quickly does not hitch.
*
* The first snapshot for a window (registration) never triggers a collection,
* so a window that starts unfocused before its first paint cannot GC during
* startup.
*/
internal class IdleGcController {
private val lock = Any()
private val windows = LinkedHashMap<Any, WindowIdle>()
private var deferredArmed: Boolean = false

fun register(
id: Any,
focused: Boolean,
minimized: Boolean,
) {
synchronized(lock) {
windows[id] = WindowIdle(focused, minimized)
}
}

fun unregister(id: Any): IdleGcCommand =
synchronized(lock) {
if (windows.remove(id) == null) IdleGcCommand.NoChange else commit(decide())
}

fun update(
id: Any,
focused: Boolean,
minimized: Boolean,
): IdleGcCommand =
synchronized(lock) {
val next = WindowIdle(focused, minimized)
val prev = windows[id] ?: return@synchronized IdleGcCommand.NoChange
if (prev == next) return@synchronized IdleGcCommand.NoChange
windows[id] = next
commit(decide())
}

/**
* True when a deferred (unfocus) collection was armed and is still valid:
* every tracked window is unfocused and none is minimized. Minimize already
* collected immediately, so the delay must not fire a second time.
*/
fun shouldRunDeferredGc(): Boolean =
synchronized(lock) {
deferredArmed && decide() == IdleGcCommand.Debounce
}

private fun decide(): IdleGcCommand {
if (windows.isEmpty() || windows.values.any { it.isInteracting }) {
return IdleGcCommand.Cancel
}
if (windows.values.any { it.minimized }) {
return IdleGcCommand.CollectNow
}
return IdleGcCommand.Debounce
}

private fun commit(cmd: IdleGcCommand): IdleGcCommand {
when (cmd) {
IdleGcCommand.Debounce -> deferredArmed = true
IdleGcCommand.Cancel, IdleGcCommand.CollectNow -> deferredArmed = false
IdleGcCommand.NoChange -> Unit
}
return cmd
}

private data class WindowIdle(
val focused: Boolean,
val minimized: Boolean,
) {
val isInteracting: Boolean get() = focused && !minimized
}

companion object {
const val UNFOCUS_DELAY_MS: Long = 3_000
}
}

internal enum class IdleGcCommand {
NoChange,
Cancel,
Debounce,
CollectNow,
}
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ private fun TaoDecoratedDialogScope.bindNucleusDialogContent(
remember(taoScope, nucleusWindow) {
TaoNucleusDecoratedDialogScope(taoScope, nucleusWindow)
}
ObserveIdleGc(nucleusWindow)
// Bridge the parent composition's locals (theme, density,
// user-provided locals, …) into the dialog's own ComposeScene
// via `ComposeScene.compositionLocalContext` rather than a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ internal fun TaoDecoratedWindowScope.bindNucleusContent(
TaoNucleusDecoratedWindowScope(taoScope, nucleusWindow)
}
ObserveSingleInstanceRestore(nucleusWindow)
ObserveIdleGc(nucleusWindow)
// outerLocals were captured in the OUTER composition and cross the
// scene boundary as this scene's own compositionLocalContext (the
// parameter above for the first composition, the bridge below for
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package dev.nucleusframework.application.internal

import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

class IdleGcControllerTest {
@Test
fun `registering an unfocused window does not schedule gc`() {
val c = IdleGcController()
c.register("w", focused = false, minimized = false)
assertEquals(IdleGcCommand.NoChange, c.update("w", focused = false, minimized = false))
assertFalse(c.shouldRunDeferredGc())
}

@Test
fun `unfocus schedules a deferred collection`() {
val c = IdleGcController()
c.register("w", focused = true, minimized = false)
assertEquals(IdleGcCommand.Debounce, c.update("w", focused = false, minimized = false))
assertTrue(c.shouldRunDeferredGc())
}

@Test
fun `refocus before the delay cancels deferred collection`() {
val c = IdleGcController()
c.register("w", focused = true, minimized = false)
assertEquals(IdleGcCommand.Debounce, c.update("w", focused = false, minimized = false))
assertEquals(IdleGcCommand.Cancel, c.update("w", focused = true, minimized = false))
assertFalse(c.shouldRunDeferredGc())
}

@Test
fun `minimize collects immediately`() {
val c = IdleGcController()
c.register("w", focused = true, minimized = false)
assertEquals(IdleGcCommand.CollectNow, c.update("w", focused = false, minimized = true))
assertFalse(c.shouldRunDeferredGc())
}

@Test
fun `minimize of a still-focused window collects immediately`() {
val c = IdleGcController()
c.register("w", focused = true, minimized = false)
assertEquals(IdleGcCommand.CollectNow, c.update("w", focused = true, minimized = true))
assertFalse(c.shouldRunDeferredGc())
}

@Test
fun `unfocus then minimize upgrades debounce to immediate`() {
val c = IdleGcController()
c.register("w", focused = true, minimized = false)
assertEquals(IdleGcCommand.Debounce, c.update("w", focused = false, minimized = false))
assertEquals(IdleGcCommand.CollectNow, c.update("w", focused = false, minimized = true))
assertFalse(c.shouldRunDeferredGc())
}

@Test
fun `second window still focused cancels idle gc`() {
val c = IdleGcController()
c.register("a", focused = true, minimized = false)
c.register("b", focused = false, minimized = false)
assertEquals(IdleGcCommand.Cancel, c.update("b", focused = true, minimized = false))
assertEquals(IdleGcCommand.Cancel, c.update("a", focused = false, minimized = false))
assertFalse(c.shouldRunDeferredGc())
}

@Test
fun `last focused window unfocusing schedules deferred collection`() {
val c = IdleGcController()
c.register("a", focused = true, minimized = false)
c.register("b", focused = false, minimized = false)
c.update("b", focused = true, minimized = false)
c.update("a", focused = false, minimized = false)
assertEquals(IdleGcCommand.Debounce, c.update("b", focused = false, minimized = false))
assertTrue(c.shouldRunDeferredGc())
}

@Test
fun `minimize is skipped while another window is interacting`() {
val c = IdleGcController()
c.register("a", focused = true, minimized = false)
c.register("b", focused = false, minimized = false)
assertEquals(IdleGcCommand.Cancel, c.update("b", focused = false, minimized = true))
assertFalse(c.shouldRunDeferredGc())
}

@Test
fun `dialog focus keeps the app interacting`() {
val c = IdleGcController()
c.register("window", focused = true, minimized = false)
c.register("dialog", focused = false, minimized = false)
c.update("window", focused = false, minimized = false)
assertEquals(IdleGcCommand.Cancel, c.update("dialog", focused = true, minimized = false))
assertFalse(c.shouldRunDeferredGc())
}

@Test
fun `unregistering the last window cancels pending collection`() {
val c = IdleGcController()
c.register("w", focused = true, minimized = false)
c.update("w", focused = false, minimized = false)
assertEquals(IdleGcCommand.Cancel, c.unregister("w"))
assertFalse(c.shouldRunDeferredGc())
}

@Test
fun `update after unregister is ignored`() {
val c = IdleGcController()
c.register("w", focused = true, minimized = false)
c.unregister("w")
assertEquals(IdleGcCommand.NoChange, c.update("w", focused = false, minimized = true))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,35 @@ abstract class JvmApplication {
*/
abstract var garbageCollector: GarbageCollector?

/**
* Master switch for the desktop startup pack: Serial GC, compact heap
* (`-Xms32m`, `-XX:MaxRAMPercentage=25`), a single JAR in the jpackage
* image, idle GC (3s after last unfocus, immediately on minimize), and
* the current OpenJDK as the jpackage / jlink / `run` JDK (auto-downloaded,
* like the GraalVM toolchain).
*
* `true` turns on every knob still unset in the [nucleusOptimization]
* configure block. An explicit [garbageCollector], [javaHome], or `-Xms` /
* `-XX:MaxRAMPercentage` in [jvmArgs] is left unchanged.
*
* Does not enable AOT; set [JvmApplicationDistributions.enableAotCache]
* separately. Does not change the Gradle compile JDK.
*/
abstract var nucleusOptimization: Boolean

/**
* Per-knob overrides for [nucleusOptimization]. `null` follows the master
* boolean; `true` / `false` force that piece on or off.
*
* ```
* nucleusOptimization = true
* nucleusOptimization { idleGc = false }
*
* nucleusOptimization { singleJar = true }
* ```
*/
abstract fun nucleusOptimization(fn: Action<NucleusOptimizationSettings>)

abstract val nativeDistributions: JvmApplicationDistributions

abstract fun nativeDistributions(fn: Action<JvmApplicationDistributions>)
Expand Down
Loading
Loading