diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt index c4c34a1db9..783c438f66 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt @@ -29,6 +29,7 @@ import java.net.Proxy import java.nio.file.Files import java.nio.file.StandardCopyOption import kotlin.math.min +import kotlin.system.exitProcess data class SemVer( val major: Int, @@ -315,6 +316,10 @@ private suspend fun downloadAsset(asset: GitHubAsset) { call.execute().use { response -> response.body?.use { body -> body.byteStream().use { stream -> + // On Windows the install path exits the app before it can delete the downloaded file, so a + // previous update's installer can be left in the temp dir; remove it here at the next + // download instead of letting it accumulate. Other platforms delete the file after install. + if (desktopPlatform.isWindows()) File(tmpDir, asset.name).delete() createTmpFileAndDelete { file -> // It's important to close output stream (with use{}), otherwise, Windows cannot rename the file file.outputStream().use { output -> @@ -433,19 +438,12 @@ private suspend fun installAppUpdate(file: File) = withContext(Dispatchers.IO) { } } desktopPlatform.isWindows() -> { - val process = Runtime.getRuntime().exec("msiexec /i ${file.absolutePath}"/* /qb */).onExit().join() - val startedInstallation = process.exitValue() == 0 - if (!startedInstallation) { - Log.e(TAG, "Error starting installation: ${process.inputReader().use { it.readLines().joinToString("\n") }}${process.errorStream.use { String(it.readAllBytes()) }}") - // Failed to start installation. show directory with the file for manual installation - desktopOpenDir(file.parentFile) - } else { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.app_check_for_updates_installed_successfully_title), - text = generalGetString(MR.strings.app_check_for_updates_installed_successfully_desc) - ) - file.delete() - } + // Launch the installer, then exit so our files are no longer locked. While the app runs it + // holds SimpleX.exe/JRE/DLLs open, which forces the MSI to defer replacement to a reboot and + // corrupts the upgrade (the app then fails to launch). Array form passes the path as a single + // argument so a space in the temp path does not break the command. + Runtime.getRuntime().exec(arrayOf("msiexec", "/i", file.absolutePath)) + exitProcess(0) } desktopPlatform.isMac() -> { // Default mount point if no other DMGs were mounted before diff --git a/plans/2026-06-23-fix-windows-msi-update-running-app.md b/plans/2026-06-23-fix-windows-msi-update-running-app.md new file mode 100644 index 0000000000..d276dbfe6f --- /dev/null +++ b/plans/2026-06-23-fix-windows-msi-update-running-app.md @@ -0,0 +1,83 @@ +# Fix: Windows desktop fails to launch after in-app MSI update + +Issue: https://github.com/simplex-chat/simplex-chat/issues/7105 + +## Symptom + +After updating SimpleX Desktop on Windows via the in-app updater (MSI), the MSI reports +success but `SimpleX.exe` never launches afterwards — no window, no process in Task Manager, +and it persists across a reboot. No application crash is logged. Reported on Windows 11 +(6.5.4 → 7.0.0), and the reporter saw it on a prior update too. + +Event Viewer shows the cause: RestartManager 10010 (`SimpleX.exe … cannot be restarted - +Application SID does not match Conductor SID`) followed by MsiInstaller 1038 +(`requires a system restart … Reason for Restart: 1` = files in use). + +## Root cause + +The Windows branch of `installAppUpdate` (`AppUpdater.kt`) ran the MSI **while the app was +still running** and waited for it (`Runtime.getRuntime().exec("msiexec /i …").onExit().join()`). +The desktop MSI is a per-machine major upgrade (`desktop/build.gradle.kts`: +`perUserInstall = false`, fixed `upgradeUuid`), so it must replace `SimpleX.exe`, the bundled +JRE and the app DLLs in `C:\Program Files\SimpleX`. The running JVM holds those files open, so +the installer cannot replace them: Restart Manager can't close the app across the +user/elevated security boundary (the "SID does not match" event), the locked files are +deferred to `PendingFileRenameOperations`, and a reboot is requested. At reboot the deferred +uninstall-then-install operations leave the install directory inconsistent, so the executable +never starts. + +macOS and Linux are unaffected because those kernels allow a running executable's file to be +replaced by inode; Windows locks open files, so the app must release its locks before the MSI +replaces them. + +## Fix + +Launch the installer and **exit the app immediately** instead of waiting, so the files are +unlocked before the MSI reaches its file-replacement phase (which happens only after UAC +elevation and the installer's costing phase — well after the JVM is gone). With no running +instance there are no locked files, no reboot deferral and no Restart Manager conflict. + +Two small changes, both confined to the Windows path: + +1. `installAppUpdate` Windows branch: replace the `msiexec … .onExit().join()` + result + handling with `Runtime.getRuntime().exec(arrayOf("msiexec", "/i", file.absolutePath))` + followed by `exitProcess(0)`. The array form also fixes a latent bug where a space in the + path broke the single-string `exec`. +2. `downloadAsset`: before downloading, `if (desktopPlatform.isWindows()) File(tmpDir, + asset.name).delete()`. Because the app now exits before it can delete the installer, a + leftover MSI is removed at the next download instead of accumulating in the temp dir. + +The user reopens the updated app from the Start Menu (the MSI recreates the shortcut) — the +same hand-off the updater already uses for the `.deb` path and all failure branches. + +## Alternatives considered + +- **Helper script that waits for exit, installs, then relaunches** — adds a generated batch + with PID-polling, relaunch and self-delete; its only benefit over exiting is auto-relaunch, + which is not worth the complexity and platform-specific fragility. +- **Register the app with Restart Manager** so the elevated MSI can close/restart it — + jpackage apps don't register RM, the SID mismatch shows RM can't manage the process across + the elevation boundary anyway, and it is far more code. +- **Keep the app running with `REBOOT=ReallySuppress` / repair flags** — does not address the + lock; in-use files still defer to a reboot. + +## Scope / out of scope + +- In scope: the Windows branch of `installAppUpdate` and a Windows-guarded cleanup in + `downloadAsset`. macOS, Linux AppImage and `.deb` paths are unchanged. +- Trade-off accepted: the app no longer shows its own "installed successfully" dialog (it has + exited); msiexec shows its own progress UI and the relaunched app is the success signal. +- Out of scope (separate follow-ups): the macOS install branch ignores `renameTo` results; + download failures are only logged with no user-facing error; no signature/SHA verification + of the downloaded artifact. + +## Test plan (Windows VM) + +1. Install a per-machine MSI built at a version below the latest release. +2. Settings → Check for updates → download + Install the newer MSI. +3. Before the fix: RestartManager 10010 + MsiInstaller 1038 (Reason 1) appear and the app + fails to launch after reboot (reproduces #7105). +4. After the fix: the app exits, the MSI installs with no reboot request, and the new version + launches normally from the Start Menu; `C:\Program Files\SimpleX` is a complete install + with no queued `PendingFileRenameOperations`. +5. Re-run with a user profile path containing a space to confirm the array-form `exec` fix.