From 05c3e4b3a532653be33d0c274d5e5ec0df900668 Mon Sep 17 00:00:00 2001 From: lxpollitt <630494+lxpollitt@users.noreply.github.com> Date: Sun, 7 Jun 2026 17:57:44 +0100 Subject: [PATCH] Fix Swing dialog deadlock on macOS desktop build On macOS, the lwjgl3 desktop build requires -XstartOnFirstThread for GLFW, which means the libGDX render thread is also the AppKit main thread. Swing dialog calls (JFileChooser.showOpenDialog, JOptionPane.showXxxDialog, etc.) were being dispatched onto the libGDX render thread via Gdx.app.postRunnable, which deadlocked because the Swing API normally expects to dispatch through the AWT Event Dispatch Thread, which in turn needs the macOS main thread to be free. DesktopDialogHandler: route the five dialog methods (confirm, openFileDialog, promptForTextInput, showAboutDialog, promptForOption) through SwingUtilities.invokeLater. The response handler is then posted back via Gdx.app.postRunnable so it runs on the libGDX render thread, keeping libGDX state mutations safe. DesktopLauncher: pre-initialise AWT (Toolkit.getDefaultToolkit) before the Lwjgl3Application constructor takes the main thread. Without this, the first Swing call later would try to initialise AppKit from a non-main thread and hang. Pre-initialising claims AppKit before libGDX takes the main thread. --- .../joric/lwjgl3/DesktopDialogHandler.java | 235 +++++++++++------- .../emu/joric/lwjgl3/DesktopLauncher.java | 10 + 2 files changed, 153 insertions(+), 92 deletions(-) diff --git a/lwjgl3/src/main/java/emu/joric/lwjgl3/DesktopDialogHandler.java b/lwjgl3/src/main/java/emu/joric/lwjgl3/DesktopDialogHandler.java index c54d18f..47d26e6 100644 --- a/lwjgl3/src/main/java/emu/joric/lwjgl3/DesktopDialogHandler.java +++ b/lwjgl3/src/main/java/emu/joric/lwjgl3/DesktopDialogHandler.java @@ -16,6 +16,7 @@ import javax.swing.JDialog; import javax.swing.JFileChooser; import javax.swing.JOptionPane; +import javax.swing.SwingUtilities; import javax.swing.filechooser.FileNameExtensionFilter; import javax.swing.filechooser.FileSystemView; @@ -104,17 +105,30 @@ private Icon getResetIcon() { @Override public void confirm(final String message, final ConfirmResponseHandler responseHandler) { - Gdx.app.postRunnable(new Runnable() { + // The Swing dialog must run on the AWT Event Dispatch Thread (EDT), + // not on the libGDX main thread. The Swing API normally expects its + // components to be accessed from the AWT EDT. On macOS the libGDX main + // thread is also the AppKit main thread (because of -XstartOnFirstThread), + // so if we made Swing dialog calls from there they would deadlock against + // the EDT. So we dispatch via SwingUtilities.invokeLater, and post the + // response back to the libGDX main thread so the response handler interacts + // with libGDX state safely. + SwingUtilities.invokeLater(new Runnable() { @Override public void run() { dialogOpen = true; - int output = JOptionPane.showConfirmDialog(null, message, "Please confirm", JOptionPane.YES_NO_OPTION); + final int output = JOptionPane.showConfirmDialog(null, message, "Please confirm", JOptionPane.YES_NO_OPTION); dialogOpen = false; - if (output != 0) { - responseHandler.no(); - } else { - responseHandler.yes(); - } + Gdx.app.postRunnable(new Runnable() { + @Override + public void run() { + if (output != 0) { + responseHandler.no(); + } else { + responseHandler.yes(); + } + } + }); } }); } @@ -122,7 +136,9 @@ public void run() { @Override public void openFileDialog(String title, final String startPath, final OpenFileResponseHandler openFileResponseHandler) { - Gdx.app.postRunnable(new Runnable() { + // See confirm() for why the Swing dialog is dispatched onto the EDT + // and the response is posted back to the libGDX main thread. + SwingUtilities.invokeLater(new Runnable() { @Override public void run() { @@ -138,14 +154,21 @@ public void run() { jfc.addChoosableFileFilter(filter); dialogOpen = true; - int returnValue = jfc.showOpenDialog(null); + final int returnValue = jfc.showOpenDialog(null); + final String selectedPath = (returnValue == JFileChooser.APPROVE_OPTION) + ? jfc.getSelectedFile().getPath() : null; dialogOpen = false; - if (returnValue == JFileChooser.APPROVE_OPTION) { - System.out.println(jfc.getSelectedFile().getPath()); - openFileResponseHandler.openFileResult(true, jfc.getSelectedFile().getPath(), null); - } else { - openFileResponseHandler.openFileResult(false, null, null); - } + Gdx.app.postRunnable(new Runnable() { + @Override + public void run() { + if (selectedPath != null) { + System.out.println(selectedPath); + openFileResponseHandler.openFileResult(true, selectedPath, null); + } else { + openFileResponseHandler.openFileResult(false, null, null); + } + } + }); } }); } @@ -153,105 +176,133 @@ public void run() { @Override public void promptForTextInput(final String message, final String initialValue, final TextInputResponseHandler textInputResponseHandler) { - Gdx.app.postRunnable(new Runnable() { + // See confirm() for why the Swing dialog is dispatched onto the EDT + // and the response is posted back to the libGDX main thread. + SwingUtilities.invokeLater(new Runnable() { @Override public void run() { dialogOpen = true; - String text = (String) JOptionPane.showInputDialog(null, message, "Please enter value", + final String text = (String) JOptionPane.showInputDialog(null, message, "Please enter value", JOptionPane.INFORMATION_MESSAGE, null, null, initialValue != null ? initialValue : ""); dialogOpen = false; - - if (text != null) { - textInputResponseHandler.inputTextResult(true, text); - } else { - textInputResponseHandler.inputTextResult(false, null); - } + Gdx.app.postRunnable(new Runnable() { + @Override + public void run() { + if (text != null) { + textInputResponseHandler.inputTextResult(true, text); + } else { + textInputResponseHandler.inputTextResult(false, null); + } + } + }); } }); } @Override - public void showAboutDialog(String aboutMessage, TextInputResponseHandler textInputResponseHandler) { - dialogOpen = true; - - JButton spacerButton = new JButton( - " "); - spacerButton.setVisible(false); - JButton exportButton = new JButton(getExportIcon()); - JButton importButton = new JButton(getImportIcon()); - JButton resetButton = new JButton(getResetIcon()); - JButton clearButton = new JButton(getClearIcon()); - JButton okButton = new JButton("OK"); - //Object[] options = { exportButton, importButton, resetButton, clearButton, spacerButton, okButton }; - Object[] options = { okButton }; - - final JOptionPane pane = new JOptionPane( - aboutMessage, - JOptionPane.INFORMATION_MESSAGE, - JOptionPane.DEFAULT_OPTION, - loadIcon("png/joric-64x64.png"), - options, okButton); - - MouseAdapter mouseListener = new MouseAdapter() { + public void showAboutDialog(final String aboutMessage, final TextInputResponseHandler textInputResponseHandler) { + // See confirm() for why the Swing dialog is dispatched onto the EDT + // and the response is posted back to the libGDX main thread. Unlike + // the other dialog methods this one wasn't previously wrapped in + // Gdx.app.postRunnable, but the cause was the same: it ran on + // whatever thread called it, which is the libGDX main thread from + // the in-emulator UI. + SwingUtilities.invokeLater(new Runnable() { @Override - public void mouseClicked(MouseEvent e) { - JButton button = (JButton)e.getComponent(); - if (button == exportButton) { - pane.setValue("EXPORT"); - } - else if (button == importButton) { - pane.setValue("IMPORT"); - } - else if (button == resetButton) { - pane.setValue("RESET"); - } - else if (button == clearButton) { - pane.setValue("CLEAR"); - } - else { - pane.setValue("OK"); - } + public void run() { + dialogOpen = true; + + JButton spacerButton = new JButton( + " "); + spacerButton.setVisible(false); + final JButton exportButton = new JButton(getExportIcon()); + final JButton importButton = new JButton(getImportIcon()); + final JButton resetButton = new JButton(getResetIcon()); + final JButton clearButton = new JButton(getClearIcon()); + final JButton okButton = new JButton("OK"); + //Object[] options = { exportButton, importButton, resetButton, clearButton, spacerButton, okButton }; + Object[] options = { okButton }; + + final JOptionPane pane = new JOptionPane( + aboutMessage, + JOptionPane.INFORMATION_MESSAGE, + JOptionPane.DEFAULT_OPTION, + loadIcon("png/joric-64x64.png"), + options, okButton); + + MouseAdapter mouseListener = new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + JButton button = (JButton)e.getComponent(); + if (button == exportButton) { + pane.setValue("EXPORT"); + } + else if (button == importButton) { + pane.setValue("IMPORT"); + } + else if (button == resetButton) { + pane.setValue("RESET"); + } + else if (button == clearButton) { + pane.setValue("CLEAR"); + } + else { + pane.setValue("OK"); + } + } + }; + + exportButton.addMouseListener(mouseListener); + importButton.addMouseListener(mouseListener); + resetButton.addMouseListener(mouseListener); + clearButton.addMouseListener(mouseListener); + okButton.addMouseListener(mouseListener); + + pane.setComponentOrientation(JOptionPane.getRootFrame().getComponentOrientation()); + JDialog dialog = pane.createDialog("About JOric"); + dialog.setIconImage(loadImage("png/joric-32x32.png")); + dialog.show(); + dialog.dispose(); + + final Object paneValue = pane.getValue(); + dialogOpen = false; + Gdx.app.postRunnable(new Runnable() { + @Override + public void run() { + if (paneValue != null) { + textInputResponseHandler.inputTextResult(true, (String)paneValue); + } else { + textInputResponseHandler.inputTextResult(false, null); + } + } + }); } - }; - - exportButton.addMouseListener(mouseListener); - importButton.addMouseListener(mouseListener); - resetButton.addMouseListener(mouseListener); - clearButton.addMouseListener(mouseListener); - okButton.addMouseListener(mouseListener); - - pane.setComponentOrientation(JOptionPane.getRootFrame().getComponentOrientation()); - JDialog dialog = pane.createDialog("About JOric"); - dialog.setIconImage(loadImage("png/joric-32x32.png")); - dialog.show(); - dialog.dispose(); - - if (pane.getValue() != null) { - textInputResponseHandler.inputTextResult(true, (String)pane.getValue()); - } else { - textInputResponseHandler.inputTextResult(false, null); - } - - dialogOpen = false; + }); } - + @Override public void promptForOption(final String title, final String message, final String[] options, final String currentSelection, final TextInputResponseHandler textInputResponseHandler) { - Gdx.app.postRunnable(new Runnable() { + // See confirm() for why the Swing dialog is dispatched onto the EDT + // and the response is posted back to the libGDX main thread. + SwingUtilities.invokeLater(new Runnable() { @Override public void run() { dialogOpen = true; String dialogTitle = (title != null && !title.isEmpty()) ? title : "JOric"; - Object result = JOptionPane.showInputDialog(null, message, dialogTitle, + final Object result = JOptionPane.showInputDialog(null, message, dialogTitle, JOptionPane.QUESTION_MESSAGE, null, options, currentSelection); dialogOpen = false; - - if (result != null) { - textInputResponseHandler.inputTextResult(true, result.toString()); - } else { - textInputResponseHandler.inputTextResult(false, null); - } + Gdx.app.postRunnable(new Runnable() { + @Override + public void run() { + if (result != null) { + textInputResponseHandler.inputTextResult(true, result.toString()); + } else { + textInputResponseHandler.inputTextResult(false, null); + } + } + }); } }); } diff --git a/lwjgl3/src/main/java/emu/joric/lwjgl3/DesktopLauncher.java b/lwjgl3/src/main/java/emu/joric/lwjgl3/DesktopLauncher.java index 0ac93b3..cd59b50 100644 --- a/lwjgl3/src/main/java/emu/joric/lwjgl3/DesktopLauncher.java +++ b/lwjgl3/src/main/java/emu/joric/lwjgl3/DesktopLauncher.java @@ -14,6 +14,16 @@ public class DesktopLauncher { public static void main(String[] args) { if (StartupHelper.startNewJvmIfRequired()) return; // This handles macOS support and helps on Windows. + // Pre-initialize AWT before libGDX claims the main thread. On macOS, + // -XstartOnFirstThread gives the main thread to GLFW, but AWT also + // requires the main thread to initialise its AppKit backend the + // first time it's used. If we let that first use happen later (e.g. + // when a JFileChooser is opened by DesktopDialogHandler), AppKit + // init hangs forever waiting for a main thread that GLFW now owns. + // Calling Toolkit.getDefaultToolkit() here forces AppKit init to + // happen while the main thread is still ours, so later AWT calls + // from any thread find it already initialised. + java.awt.Toolkit.getDefaultToolkit(); createApplication(convertArgsToMap(args)); }