desktop: show startup errors in a copyable window instead of bare "Failed to launch JVM" (#7261)

* desktop: show startup errors in a copyable window instead of bare "Failed to launch JVM"

When any exception escapes main() before the app window appears - a missing
DLL, a failed migration, broken AWT init - the jpackage launcher shows only
"Failed to launch JVM" and the cause is recorded nowhere: the launcher runs
without a console, so stderr is lost. Every report in #4146 stalled on this.

Catch the error and show it in a native Win32 window laid out like a message
box: an error icon and message, two clickable report links (the GitHub issue
tracker and the support email) above a read-only selectable box with the stack
trace, and an OK button. The links are SS_NOTIFY statics opened with
ShellExecute (browser for the URL, mail client for the email). Native, not
Swing, because broken AWT initialization is one of the failure causes. On
Windows the process then exits cleanly so the launcher does not also show its
own box; on other systems the error is rethrown to stderr.

* docs: plan justifying desktop startup error window (#4146)
This commit is contained in:
Narasimha-sc
2026-07-17 15:41:54 +01:00
committed by GitHub
parent d60da754dc
commit 11f0b6bd51
3 changed files with 284 additions and 10 deletions
@@ -0,0 +1,164 @@
package chat.simplex.common.platform
import com.sun.jna.*
import com.sun.jna.platform.win32.*
import com.sun.jna.platform.win32.WinDef.*
import com.sun.jna.platform.win32.WinUser.*
import kotlin.system.exitProcess
// Windows-only: shows a startup error the jpackage launcher otherwise hides behind "Failed to launch
// JVM" (#4146); on Linux/Mac the error rethrown by the caller reaches stderr. Uses a native window,
// not Swing, because broken AWT initialization can be the cause. Laid out like a message box: an error
// icon and a message at the top, two clickable report links (the GitHub issue tracker and the support
// email) above a read-only, selectable, scrolling box with the stack trace, and an OK button.
fun showStartupError(e: Throwable) {
if (!desktopPlatform.isWindows()) return
try {
// Win32 edit controls only break lines on CRLF; normalize every line ending to one CRLF so the
// trace does not render as merged lines (the JVM's own separator is CRLF on Windows, LF elsewhere)
showWindowsErrorWindow("SimpleX failed to start", e.stackTraceToString().lines().joinToString("\r\n"))
} catch (_: Throwable) {
return // dialog failed to show; let the caller rethrow so the launcher's own box is the fallback
}
// The dialog was shown and dismissed. Exit cleanly so the jpackage launcher does not also show its
// own generic "Failed to launch JVM" box on top of ours (it shows that box on any nonzero exit).
exitProcess(0)
}
private const val ISSUES_URL = "https://github.com/simplex-chat/simplex-chat/issues"
private const val SUPPORT_EMAIL = "chat@simplex.chat"
// Win32 constants not defined in jna-platform
private const val ES_MULTILINE = 0x0004
private const val ES_READONLY = 0x0800
private const val ES_AUTOVSCROLL = 0x0040
private const val WS_EX_CLIENTEDGE = 0x0200
private const val CW_USEDEFAULT = 0x80000000.toInt()
private const val WM_SETTEXT = 0x000C
private const val WM_SETFONT = 0x0030
private const val WM_COMMAND = 0x0111
private const val WM_CTLCOLORSTATIC = 0x0138
private const val EM_SETLIMITTEXT = 0x00C5
private const val SS_ICON = 0x0003
private const val SS_NOTIFY = 0x0100
private const val STM_SETICON = 0x0170
private const val COLOR_WINDOW = 5
private const val WHITE_BRUSH = 0
private const val LINK_COLOR = 0x00EE0000 // COLORREF 0x00BBGGRR = link blue RGB(0,0,238)
private const val IDI_ERROR = 32513
private const val APP_ICON_ID = 1 // the app icon jpackage embeds in the launcher exe
private const val DEFAULT_GUI_FONT = 17
private const val OK_BUTTON_ID = 1
private const val URL_LINK_ID = 2
private const val EMAIL_LINK_ID = 3
// Shows a modal-style native error dialog: error icon and message, two clickable report-link statics,
// a read-only selectable edit control with the stack trace, and an OK button. Blocks on its own
// message loop until the window is closed.
private fun showWindowsErrorWindow(title: String, trace: String) {
val user32 = User32.INSTANCE
val user32native = NativeLibrary.getInstance("user32")
val gdi32 = NativeLibrary.getInstance("gdi32")
val shellExecute = NativeLibrary.getInstance("shell32").getFunction("ShellExecuteW")
val sendMessageW = user32native.getFunction("SendMessageW")
val hInstance = Kernel32.INSTANCE.GetModuleHandle(null)
val className = "SimpleXStartupError"
// Paint static backgrounds white to match the window, and draw the two link statics in blue
val whiteBrush = gdi32.getFunction("GetStockObject").invokePointer(arrayOf<Any?>(Integer.valueOf(WHITE_BRUSH)))
val setTextColor = gdi32.getFunction("SetTextColor")
// The link statics' HWNDs, set once created and read back in the paint callback
var urlLinkHwnd = 0L
var emailLinkHwnd = 0L
fun open(target: String) = shellExecute.invokePointer(arrayOf<Any?>(
Pointer.NULL, WString("open"), WString(target), Pointer.NULL, Pointer.NULL, Integer.valueOf(SW_SHOWNORMAL)))
val wndProc = WindowProc { hwnd, uMsg, wParam, lParam ->
when {
// A click on the OK button or a link static arrives as WM_COMMAND with the control id in LOWORD
uMsg == WM_COMMAND -> {
when (wParam.toInt() and 0xFFFF) {
OK_BUTTON_ID -> user32.DestroyWindow(hwnd)
URL_LINK_ID -> open(ISSUES_URL) // open the issue tracker in a browser
EMAIL_LINK_ID -> open("mailto:$SUPPORT_EMAIL") // open the mail client
}
LRESULT(0)
}
uMsg == WM_CTLCOLORSTATIC -> {
if (lParam.toLong() == urlLinkHwnd || lParam.toLong() == emailLinkHwnd) {
setTextColor.invokeInt(arrayOf<Any?>(Pointer.createConstant(wParam.toLong()), Integer.valueOf(LINK_COLOR)))
}
LRESULT(Pointer.nativeValue(whiteBrush))
}
uMsg == WM_DESTROY -> { user32.PostQuitMessage(0); LRESULT(0) }
else -> user32.DefWindowProc(hwnd, uMsg, wParam, lParam)
}
}
// Prefer the app's own icon so the title bar/taskbar is not the default "unknown" icon; fall back
// to the system error icon (also shown in the window below).
val hErrorIcon = user32native.getFunction("LoadIconW").invokePointer(arrayOf<Any?>(Pointer.NULL, Pointer.createConstant(IDI_ERROR)))
val hAppIcon = user32native.getFunction("LoadIconW").invokePointer(arrayOf<Any?>(hInstance, Pointer.createConstant(APP_ICON_ID)))
val windowIcon = hAppIcon ?: hErrorIcon // invokePointer returns null when the exe has no such icon
val windowClass = WNDCLASSEX()
windowClass.cbSize = windowClass.size()
windowClass.lpfnWndProc = wndProc
windowClass.hInstance = hInstance
windowClass.lpszClassName = className
windowClass.hbrBackground = HBRUSH(Pointer.createConstant(COLOR_WINDOW + 1)) // system window (light) color
windowClass.hIcon = HICON(windowIcon)
windowClass.hIconSm = HICON(windowIcon)
user32.RegisterClassEx(windowClass)
// If the window cannot be created, throw rather than enter the message loop: with no window,
// GetMessage would block forever. The caller's catch then rethrows and the launcher box is shown.
val window = user32.CreateWindowEx(
0, className, title, WS_CAPTION or WS_SYSMENU or WS_VISIBLE,
CW_USEDEFAULT, CW_USEDEFAULT, 760, 520,
null, null, hInstance, null
) ?: throw IllegalStateException("failed to create startup error window")
val client = RECT()
user32.GetClientRect(window, client)
val cw = client.right
val ch = client.bottom
val iconView = user32.CreateWindowEx(0, "STATIC", null, WS_CHILD or WS_VISIBLE or SS_ICON,
18, 18, 32, 32, window, null, hInstance, null)
user32.SendMessage(iconView, STM_SETICON, WPARAM(Pointer.nativeValue(hErrorIcon)), LPARAM(0))
user32.CreateWindowEx(0, "STATIC", "SimpleX could not start. Copy the error below and report it via:",
WS_CHILD or WS_VISIBLE, 60, 20, cw - 80, 20, window, null, hInstance, null)
val urlLink = user32.CreateWindowEx(0, "STATIC", ISSUES_URL, WS_CHILD or WS_VISIBLE or SS_NOTIFY,
60, 46, cw - 80, 18, window, HMENU(Pointer.createConstant(URL_LINK_ID)), hInstance, null)
urlLinkHwnd = Pointer.nativeValue(urlLink.pointer)
val emailLink = user32.CreateWindowEx(0, "STATIC", SUPPORT_EMAIL, WS_CHILD or WS_VISIBLE or SS_NOTIFY,
60, 68, cw - 80, 18, window, HMENU(Pointer.createConstant(EMAIL_LINK_ID)), hInstance, null)
emailLinkHwnd = Pointer.nativeValue(emailLink.pointer)
val traceView = user32.CreateWindowEx(WS_EX_CLIENTEDGE, "EDIT", null,
WS_CHILD or WS_VISIBLE or WS_VSCROLL or ES_MULTILINE or ES_READONLY or ES_AUTOVSCROLL,
18, 96, cw - 36, ch - 96 - 54, window, null, hInstance, null)
user32.SendMessage(traceView, EM_SETLIMITTEXT, WPARAM(0), LPARAM(0)) // do not truncate long traces
sendMessageW.invokePointer(arrayOf<Any?>(traceView, Integer.valueOf(WM_SETTEXT), Pointer.NULL, WString(trace)))
val okButton = user32.CreateWindowEx(0, "BUTTON", "OK", WS_CHILD or WS_VISIBLE or BS_DEFPUSHBUTTON,
cw - 98, ch - 42, 80, 30, window, HMENU(Pointer.createConstant(OK_BUTTON_ID)), hInstance, null)
// The default control font is the dated bitmap "System" font; use the standard dialog font
val hFont = gdi32.getFunction("GetStockObject").invokePointer(arrayOf<Any?>(Integer.valueOf(DEFAULT_GUI_FONT)))
for (view in listOf(urlLink, emailLink, traceView, okButton)) {
user32.SendMessage(view, WM_SETFONT, WPARAM(Pointer.nativeValue(hFont)), LPARAM(1))
}
user32.ShowWindow(window, SW_SHOWNORMAL)
user32.SetForegroundWindow(window)
user32.SetFocus(traceView) // so Ctrl+A / Ctrl+C act on the trace immediately
val msg = MSG()
while (user32.GetMessage(msg, null, 0, 0) > 0) {
user32.TranslateMessage(msg)
user32.DispatchMessage(msg)
}
}
@@ -20,16 +20,24 @@ import kotlinx.coroutines.*
import java.io.File
fun main() {
if (!acquireSingleInstance()) return
// Disable hardware acceleration
//System.setProperty("skiko.renderApi", "SOFTWARE")
initHaskell()
runMigrations()
setupUpdateChecker()
initApp()
tmpDir.deleteRecursively()
tmpDir.mkdir()
return showApp()
try {
if (!acquireSingleInstance()) return
// Disable hardware acceleration
//System.setProperty("skiko.renderApi", "SOFTWARE")
initHaskell()
runMigrations()
setupUpdateChecker()
initApp()
tmpDir.deleteRecursively()
tmpDir.mkdir()
// showApp is inside the try: its first statements (SystemTray probe, Compose setup) are the
// process's first AWT init, which is itself a known startup failure cause (#4146). Crashes
// after the window appears are handled by the WindowExceptionHandler in showApp instead.
return showApp()
} catch (e: Throwable) {
showStartupError(e) // the jpackage launcher otherwise hides the error behind "Failed to launch JVM" (#4146)
throw e
}
}
@OptIn(ExperimentalComposeUiApi::class)
@@ -0,0 +1,102 @@
# Show desktop startup errors in a copyable window (#4146)
## Problem
When any exception escapes `main()` before the app window appears - a missing
DLL, a failed migration, broken AWT initialization - Windows users see only the
jpackage launcher's "Failed to launch JVM" box. The launcher runs without a
console, so stderr is lost, and no log file exists yet at that point. Every
report in #4146 stalled on exactly this ("no console output or log-files
whatsoever"); the same class of failure shipped once before as #5237 (wrong
OpenSSL DLL name, fixed by #5238) and was only diagnosable by rebuilding.
The launcher reports any nonzero exit as "Failed to launch JVM" (jpackage
`JvmLauncher.cpp`, `JP_THROW` on nonzero `JLI_Launch` status), so the dialog is
generic for the whole class: bad `java-options` in `SimpleX.cfg`, poisoned
`_JAVA_OPTIONS`, DLL load failures, or any uncaught startup exception.
## Change
Catch `Throwable` around the startup portion of `main()`, show the error in a
native Win32 window, and on Windows exit cleanly afterwards. The window is laid
out like a message box: a system error icon and a message at the top, then two
clickable report links (the GitHub issue tracker and the support email
chat@simplex.chat) above a read-only, selectable, scrolling box holding the stack
trace (`EDIT`, `ES_READONLY | ES_MULTILINE | WS_VSCROLL`, sunken border), and an OK
button. The links are `SS_NOTIFY` static controls drawn blue via
`WM_CTLCOLORSTATIC`; clicking one sends `WM_COMMAND` (`STN_CLICKED`) and is opened
with `ShellExecute` - the URL in a browser, the email in the mail client (with a
`mailto:` scheme). Built with jna-platform's typed `User32` plus raw calls for the
stock icon/font, the link colouring and the link launch. Nothing is written to disk.
Line endings are normalised to a single CRLF (`stackTraceToString().lines()
.joinToString("\r\n")`) because an `EDIT` control breaks lines only on CRLF - a
lone LF, or the CR-CR-LF produced by naively replacing LF with CRLF over the
JVM's already-CRLF Windows output, renders as merged lines.
A plain `MessageBoxW` was tried first but rejected: its text cannot be selected
and it has no scrollbar, truncating the trace. An all-in-one read-only rich-edit
control was tried next - it made the message, links and trace all selectable with
`EM_AUTOURLDETECT` link detection - but its `EN_LINK` click notification proved
unreliable in the packaged runtime (links highlighted but did not open), and it
placed the links inside the trace box. Dedicated `SS_NOTIFY` link statics above a
plain edit control are reliably clickable and give the cleaner requested layout;
the trade-off is that the link text itself is not selectable (the addresses are
fixed and well-known, and the full trace remains selectable/copyable).
Key placement detail: `showApp()` is inside the try block. Its first statements
(the `SystemTray.isSupported()` probe in `DesktopTray.kt`, then Compose setup)
are the process's first AWT initialization, which is itself a known startup
failure cause (#4146, fixed separately by bundling `jdk.accessibility`).
Verified empirically: a first build with `showApp()` outside the try showed no
dialog on a machine reproducing the AWT failure; moving it inside is required.
## Why this design
- Native window, not Swing: broken AWT initialization is one of the failure
causes, so a Swing dialog would crash the same way the app did. Win32 windowing
goes straight to `user32.dll` via JNA and is unaffected by the JVM's AWT state.
- The WndProc handles WM_COMMAND (OK -> close, or a link static -> ShellExecute
its URL/email by control id), WM_CTLCOLORSTATIC (white static backgrounds, plus
blue text for the two link statics) and WM_DESTROY (end the loop). The
`WindowProc` callback is held in a local for the loop's lifetime so it is not
garbage-collected while alive.
- `SS_NOTIFY` static links instead of a rich-edit control: no dependency on
Msftedit.dll or comctl32 v6, no `EN_LINK` reliability issue, and `STN_CLICKED`
is unambiguous. Simpler and it removes the rich-edit machinery entirely.
- On Windows, after the dialog is dismissed the process exits with `exitProcess(0)`
instead of rethrowing, so the launcher does not also show its own generic
"Failed to launch JVM" box on top of ours (it shows that box on any nonzero
exit). If the native window itself fails, we fall through to the caller's
`throw e`, and the launcher's box becomes the fallback.
- Windows-only dialog: on Linux/macOS the rethrown exception reaches stderr in
the terminal; the launcher-hides-everything problem is Windows-specific.
- The window icon is set to the app's own exe icon (falling back to the system
error icon) so the title bar is not the default "unknown" icon.
- try/catch in `main()`, not `Thread.setDefaultUncaughtExceptionHandler`: a
default handler would change crash handling for every thread for the app's
whole lifetime; the try/catch is scoped to startup only.
- Crashes after the window appears are out of scope: the existing
`WindowExceptionHandler` in `showApp()` (DesktopApp.kt) already surfaces
those in-app with a shareable stack trace and does not propagate to `main()`.
## Impact
- No behavior change when startup succeeds.
- On startup failure, Windows users get one error window with clickable report
links (issue tracker, email) above a selectable/scrollable stack trace - and no
longer the launcher's generic "Failed to launch JVM" box (suppressed via the
clean exit).
- The message text is hardcoded English: translated resources may not be
loadable in the failed state this code reports on.
## Verification
- Compiles on Linux; the Linux/macOS path (rethrow to stderr) is default JVM
behavior.
- Negative result that shaped the change: CI test MSI v1 (catch not covering
`showApp()`) showed no dialog on a Windows 10 machine reproducing the
assistive-technology startup failure - the crash fires at the SystemTray
probe inside `showApp()`.
- CI test MSI v2 (this change) built; confirmation on the same repro machine
that the dialog appears with the `AWTError` stack trace is pending.