Skip to content
Merged
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
3 changes: 2 additions & 1 deletion CLAUDE.md

Large diffs are not rendered by default.

22 changes: 22 additions & 0 deletions core-runtime/api/core-runtime.api
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ public final class dev/nucleusframework/core/runtime/SingleInstanceManager {
public final fun getConfiguration ()Ldev/nucleusframework/core/runtime/SingleInstanceManager$Configuration;
public final fun isSingleInstance (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Z
public static synthetic fun isSingleInstance$default (Ldev/nucleusframework/core/runtime/SingleInstanceManager;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Z
public final fun releaseForHandoff ()V
public final fun setConfiguration (Ldev/nucleusframework/core/runtime/SingleInstanceManager$Configuration;)V
}

Expand All @@ -170,6 +171,27 @@ public final class dev/nucleusframework/core/runtime/SingleInstanceManager$Confi
public fun toString ()Ljava/lang/String;
}

public final class dev/nucleusframework/core/runtime/UpdateHandoff {
public static final field ENV_HOT_INSTALL Ljava/lang/String;
public static final field ENV_PREVIOUS_PID Ljava/lang/String;
public static final field ENV_READY_FILE Ljava/lang/String;
public static final field INSTANCE Ldev/nucleusframework/core/runtime/UpdateHandoff;
public static final field RETIRED_LAUNCHER_SUFFIX Ljava/lang/String;
public static final field VERSIONS_DIR_NAME Ljava/lang/String;
public static final fun cleanupRetiredVersions ()V
public static final fun getVersionedInstall ()Ldev/nucleusframework/core/runtime/VersionedInstall;
public static final fun isHandoffLaunch ()Z
public static final fun signalReady ()V
}

public final class dev/nucleusframework/core/runtime/VersionedInstall {
public fun <init> (Ljava/io/File;Ljava/io/File;Ljava/io/File;)V
public final fun getLauncher ()Ljava/io/File;
public final fun getRoot ()Ljava/io/File;
public final fun getVersionDir ()Ljava/io/File;
public final fun getVersionsDir ()Ljava/io/File;
}

public final class dev/nucleusframework/core/runtime/WindowBackend : java/lang/Enum {
public static final field Awt Ldev/nucleusframework/core/runtime/WindowBackend;
public static final field Companion Ldev/nucleusframework/core/runtime/WindowBackend$Companion;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ public object SingleInstanceManager {
private var fileLock: FileLock? = null
private var isWatching = false

@Volatile
private var handedOff = false

/**
* Checks if the current process is the single running instance.
*
Expand Down Expand Up @@ -112,6 +115,8 @@ public object SingleInstanceManager {
}
Runtime.getRuntime().addShutdownHook(
Thread {
// After a handoff the lock file belongs to the new instance.
if (handedOff) return@Thread
releaseLock()
lockFile.delete()
deleteRestoreRequestFile()
Expand Down Expand Up @@ -175,7 +180,7 @@ public object SingleInstanceManager {
continue
}
val filename = event.context() as Path
if (filename.toString() == configuration.restoreRequestFileName) {
if (!handedOff && filename.toString() == configuration.restoreRequestFileName) {
debugLog { "Restore request file detected" }
configuration.restoreRequestFilePath.onRestoreRequest()
// Remove the request file after processing
Expand Down Expand Up @@ -225,6 +230,21 @@ public object SingleInstanceManager {
}
}

/**
* Gives up the lock while this process keeps running, so the instance it is about to launch
* becomes the single instance — the seamless restart after a hot update, where the old version
* stays on screen until the new one is. From then on this process ignores restore requests and
* leaves the lock file to its successor. No-op when the lock is not held.
*/
public fun releaseForHandoff() {
if (fileLock == null) return
handedOff = true
releaseLock()
fileLock = null
fileChannel = null
debugLog { "Lock released for an update handoff" }
}

private fun releaseLock() {
try {
fileLock?.release()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
package dev.nucleusframework.core.runtime

import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.logging.Level
import java.util.logging.Logger

/**
* A Windows NSIS install laid out for hot updates: the launcher and its `app\<name>.cfg` stay at
* [root], while the Java runtime and the application live in `versions\<version>\` ([versionDir]).
*
* A new version is installed into a sibling `versions\<new>\` directory while this one keeps
* running — nothing this process holds open is overwritten — and the rewritten `.cfg` makes the
* next launch of [launcher] start the new version.
*/
public class VersionedInstall(
/** Installation directory: holds the launcher, `app\*.cfg` and `versions\`. */
public val root: File,
/** The `versions\<version>` directory this process runs from. */
public val versionDir: File,
/** The application launcher (`jpackage.app-path`), directly under [root]. */
public val launcher: File,
) {
/** Directory holding every installed version. */
public val versionsDir: File get() = versionDir.parentFile
}

/**
* The seamless restart that follows a hot update: the running (old) version launches the new one,
* which calls [signalReady] once its first window is on screen; only then does the old version
* exit, so the application never disappears from the screen while it updates.
*
* Nucleus windows signal readiness on their first presented frame, so applications built on
* `nucleusApplication` need nothing. An application that shows no Nucleus window (tray-only, or
* its own window toolkit) calls [signalReady] itself once it is usable; otherwise the old version
* gives up waiting after a timeout and exits anyway.
*/
public object UpdateHandoff {
/**
* Set to `1` in the environment of an installer run as a hot update. The installer then leaves
* the running application alone instead of closing it, and the old version's uninstaller keeps
* its files in place.
*/
public const val ENV_HOT_INSTALL: String = "NUCLEUS_HOT_UPDATE"

/** File the new version creates once it is on screen. Set by the old version on the new one. */
public const val ENV_READY_FILE: String = "NUCLEUS_UPDATE_READY_FILE"

/**
* Comma-separated process ids of the old version (its JVM and the launcher it runs under),
* whose files the new version deletes once they have all exited.
*/
public const val ENV_PREVIOUS_PID: String = "NUCLEUS_UPDATE_PREVIOUS_PID"

/** Name of the directory holding the installed versions, under [VersionedInstall.root]. */
public const val VERSIONS_DIR_NAME: String = "versions"

/**
* Suffix of a launcher moved aside during a hot update: a running executable can be renamed but
* not overwritten, so the old launcher is renamed before the installer writes the new one.
*/
public const val RETIRED_LAUNCHER_SUFFIX: String = ".nucleus-old"

private const val RUNTIME_DIR_NAME = "runtime"
private const val TRASH_PREFIX = ".trash-"
private const val PREVIOUS_EXIT_TIMEOUT_SECONDS = 120L
private const val CLEANUP_ATTEMPTS = 10
private const val CLEANUP_RETRY_DELAY_MS = 300L

private val logger: Logger = Logger.getLogger(UpdateHandoff::class.java.name)
private val signaled = AtomicBoolean(false)

/** The versioned install this process runs from, or `null` for any other layout or platform. */
@JvmStatic
public val versionedInstall: VersionedInstall? by lazy {
detectVersionedInstall(
javaHome = System.getProperty("java.home"),
launcherPath = System.getProperty("jpackage.app-path"),
isWindows = Platform.Current == Platform.Windows,
)
}

/** Whether this process was launched by an older version handing over to it after a hot update. */
@JvmStatic
public val isHandoffLaunch: Boolean get() = System.getenv(ENV_READY_FILE) != null

/**
* Tells the version that launched this one that it is on screen, so it can exit, then deletes
* the versions left behind by earlier updates once that version is gone. Idempotent and cheap:
* the work runs on a background thread.
*/
@JvmStatic
public fun signalReady() {
if (!signaled.compareAndSet(false, true)) return
val readyFile = System.getenv(ENV_READY_FILE)
if (readyFile == null && Platform.Current != Platform.Windows) return
Thread({
readyFile?.let(::writeReadyFile)
awaitPreviousInstance()
cleanupRetiredVersions()
}, "nucleus-update-handoff").apply {
isDaemon = true
priority = Thread.MIN_PRIORITY
start()
}
}

/**
* Deletes the versions and launchers left behind by earlier hot updates. A version still in use
* (another instance running it) cannot be renamed, which is how it is detected and kept.
*/
@JvmStatic
public fun cleanupRetiredVersions() {
val install = versionedInstall ?: return
cleanupRetiredVersions(install)
}

internal fun cleanupRetiredVersions(install: VersionedInstall) {
// A process is reported gone slightly before Windows releases its image and mapped
// DLLs, so what the previous version held may need a few more attempts.
repeat(CLEANUP_ATTEMPTS) { attempt ->
if (cleanupPass(install)) return
if (attempt < CLEANUP_ATTEMPTS - 1) Thread.sleep(CLEANUP_RETRY_DELAY_MS)
}
logger.fine { "Retired versions still in use; the next start will retry" }
}

/** One cleanup pass; returns `true` when nothing retired is left. */
private fun cleanupPass(install: VersionedInstall): Boolean {
var clean = true
val current = install.versionDir.canonicalFile
install.versionsDir.listFiles()?.forEach { dir ->
if (!dir.isDirectory || dir.canonicalFile == current) return@forEach
if (dir.name.startsWith(TRASH_PREFIX)) {
if (!dir.deleteClearingReadOnly()) clean = false
return@forEach
}
// Renaming first makes the deletion all-or-nothing: Windows refuses to rename a
// directory with open files, so a version another instance still runs is left intact
// instead of losing the files it has not opened yet.
val trash = File(dir.parentFile, "$TRASH_PREFIX${dir.name}-${System.nanoTime()}")
if (!dir.renameTo(trash) || !trash.deleteClearingReadOnly()) {
logger.fine { "Could not delete retired version ${dir.name} yet" }
clean = false
}
}
install.root
.listFiles { file -> file.isFile && file.name.endsWith(RETIRED_LAUNCHER_SUFFIX) }
?.forEach {
// jpackage ships the launcher read-only, which Windows refuses to delete.
it.setWritable(true)
if (!it.delete()) {
logger.fine { "Could not delete retired launcher ${it.name} yet" }
clean = false
}
}
return clean
}

/** [File.deleteRecursively] that first clears the read-only flag Windows refuses to delete. */
private fun File.deleteClearingReadOnly(): Boolean {
walkBottomUp().filter { !it.canWrite() }.forEach { it.setWritable(true) }
return deleteRecursively()
}

private fun writeReadyFile(path: String) {
val target = File(path)
// The variable is inherited by whatever this instance starts later (a restart, say); by then
// the version that waited for it is gone along with its directory, and nobody is listening.
if (target.parentFile?.isDirectory != true) {
logger.fine { "No update handoff waiting on $path" }
return
}
try {
val temp = File(target.parentFile, "${target.name}.tmp")
temp.writeText(ProcessHandle.current().pid().toString())
Files.move(temp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING)
} catch (
@Suppress("TooGenericExceptionCaught") e: Exception,
) {
logger.log(Level.WARNING, "Could not signal the update handoff through $path", e)
}
}

private fun awaitPreviousInstance() {
val pids = System.getenv(ENV_PREVIOUS_PID)?.split(',')?.mapNotNull { it.trim().toLongOrNull() } ?: return
logger.fine { "Waiting for the previous version to exit: $pids" }
pids.forEach { pid -> awaitExit(pid) }
}

private fun awaitExit(pid: Long) {
ProcessHandle.of(pid).ifPresent { previous ->
try {
previous.onExit().get(PREVIOUS_EXIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
} catch (
@Suppress("TooGenericExceptionCaught") e: Exception,
) {
logger.log(Level.FINE, "Previous version $pid still running; cleanup may skip it", e)
}
}
}

/**
* Recognizes the versioned layout from the running JVM: `java.home` is
* `<root>\versions\<version>\runtime` and the launcher sits directly in `<root>`.
*/
internal fun detectVersionedInstall(
javaHome: String?,
launcherPath: String?,
isWindows: Boolean,
): VersionedInstall? {
if (!isWindows || javaHome == null || launcherPath == null) return null
val runtime = File(javaHome).absoluteFile
val versionDir = runtime.parentFile ?: return null
val versionsDir = versionDir.parentFile ?: return null
val root = versionsDir.parentFile ?: return null
val launcher = File(launcherPath).absoluteFile
val matches =
runtime.name.equals(RUNTIME_DIR_NAME, ignoreCase = true) &&
versionsDir.name.equals(VERSIONS_DIR_NAME, ignoreCase = true) &&
launcher.parentFile == root
return if (matches) VersionedInstall(root, versionDir, launcher) else null
}
}
Loading
Loading