From 7d30caba7491d8c929125ca450a8860d12902fdd Mon Sep 17 00:00:00 2001 From: SashegDev Date: Wed, 12 Aug 2026 12:04:01 +0000 Subject: [PATCH] =?UTF-8?q?v1.0.15.0=20=E2=80=94=20fix=20launcher=20hang?= =?UTF-8?q?=20on=20close/launch=20and=20restore=20Forge=20classpath?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hang on close (JFX freezes on exit): - LaunchService: replace process.onExit().thenRun() — it completes on the common ForkJoinPool whose non-daemon workers keep the JVM alive (the v1.0.14.2 regression re-introduced in v1.0.14.3's sendBounded) — with a daemon watcher thread that blocks on process.waitFor(). Removes ForkJoinPool pinning. - JFXLauncher: HttpServer executor now uses daemon thread factory; installThread is daemon. - ZHttpClient: drop the non-daemon CachedThreadPool executor and restore synchronous client.send() with request-level timeout, undoing the v1.0.14.3 sendAsync().get() that submitted work to a non-daemon pool. Hang / no game launch (Forge/NeoForge): - LaunchCommandBuilder: remove the self-invented -DlegacyClassPath injection. Modern Forge derives the MC-BOOTSTRAP layer from the module path / the ${classpath} substitution that AstralRinth and the 1.0.13 launcher use; a redundant -DlegacyClassPath duplicated every library and broke the bootstrap. Now relies solely on ${classpath} substitution from version.json (only built when referenced by the version.json). - update LaunchCommandBuilderTest to assert the corrected contract (no -DlegacyClassPath, ${classpath} substituted into -p). Version bump to new revision (hotfix=0): revision 1.0.14 -> 1.0.15. Build: mvn -pl launcher package -> ZernMC-win-1.0.15.0.zip, build.version=1.0.15.0. Signed-off-by: SashegDev --- .../sashegdev/zernmc/launcher/Bootstrap.java | 149 ++++++++++++++---- launcher/launcher/pom.xml | 9 ++ .../launcher/api/launch/LaunchService.java | 27 +++- .../launch/LaunchCommandBuilder.java | 81 ++++++---- .../zernmc/launcher/ui/jfx/JFXLauncher.java | 86 +++++++--- .../zernmc/launcher/utils/ZHttpClient.java | 28 ++-- .../launcher/src/resources/ui/launcher.js | 9 +- .../launch/LaunchCommandBuilderTest.java | 116 ++++++++++++++ launcher/pom.xml | 4 +- 9 files changed, 405 insertions(+), 104 deletions(-) diff --git a/launcher/bootstrap/src/main/java/me/sashegdev/zernmc/launcher/Bootstrap.java b/launcher/bootstrap/src/main/java/me/sashegdev/zernmc/launcher/Bootstrap.java index a432b71..981ac96 100644 --- a/launcher/bootstrap/src/main/java/me/sashegdev/zernmc/launcher/Bootstrap.java +++ b/launcher/bootstrap/src/main/java/me/sashegdev/zernmc/launcher/Bootstrap.java @@ -32,6 +32,7 @@ public class Bootstrap { private static final String JAR_NAME = "zernmclauncher.jar"; private static final String BASE_URL = "https://api.zernmc.ru"; private static List MIRRORS = new ArrayList<>(); + private static final List stagedSelfUpdates = new ArrayList<>(); private static volatile boolean jfxChildExiting = false; private static Path baseDir; @@ -86,10 +87,15 @@ public class Bootstrap { downloadUpdate(serverVersion); } else { log("Version is up to date"); + if (hasMissingFiles(serverVersion)) { + log("Repairing missing files..."); + downloadUpdate(serverVersion); + } } Runtime.getRuntime().addShutdownHook(new Thread(() -> { log("Shutdown signal received..."); + applyStagedSelfUpdates(); })); if (ui != null) { @@ -100,6 +106,12 @@ public class Bootstrap { ui.close(); } + if (!stagedSelfUpdates.isEmpty()) { + notifyRestartRequired(); + System.exit(0); + return; + } + launchMain(argList.toArray(new String[0])); } @@ -188,7 +200,12 @@ public class Bootstrap { log("JFX process exited with code: " + code); - if (code != 0 && !GraphicsEnvironment.isHeadless()) { + if (code == 240) { + // Deliberate exit requested for applying a staged self-update. + // Run the shutdown hook (swaps the .update file via the detached + // helper) and exit silently - this is not a crash. + System.exit(240); + } else if (code != 0 && !GraphicsEnvironment.isHeadless()) { String stderr = errorOutput.toString(); SwingUtilities.invokeLater(() -> showCrashDialog(code, stderr)); } else { @@ -327,8 +344,7 @@ public class Bootstrap { int downloaded = 0; int skipped = 0; int failed = 0; - - String selfName = getSelfFileName(); + int staged = 0; for (Map.Entry entry : serverFiles.entrySet()) { String filePath = entry.getKey(); @@ -342,13 +358,6 @@ public class Bootstrap { continue; } - // Skip self-update (can't overwrite running executable) - if (selfName != null && (filePath.equalsIgnoreCase(selfName) || filePath.endsWith("/" + selfName))) { - log("Skipping self-update: " + filePath + " (file in use)"); - skipped++; - continue; - } - if (localHash != null) { log("Updating: " + filePath); } else { @@ -356,6 +365,24 @@ public class Bootstrap { } try { + // Self-update: a running .exe cannot be overwritten on Windows, + // so download to a temp name and swap it after exit. + if (isSelfExe(filePath)) { + downloadFile(newVersion, filePath, serverMeta.size, filePath + ".update"); + Path updatePath = baseDir.resolve(filePath + ".update"); + Path targetPath = baseDir.resolve(filePath); + try { + Files.move(updatePath, targetPath, StandardCopyOption.REPLACE_EXISTING); + log("Self-update applied: " + filePath); + } catch (Exception e) { + stagedSelfUpdates.add(filePath); + staged++; + log("Self-update staged (applied on exit): " + filePath); + } + downloaded++; + continue; + } + downloadFile(newVersion, filePath, serverMeta.size); downloaded++; } catch (Exception e) { @@ -364,22 +391,86 @@ public class Bootstrap { } } - log("Updated files: " + downloaded + ", skipped: " + skipped + ", failed: " + failed); + String summary = "Updated files: " + downloaded + ", skipped: " + skipped + ", failed: " + failed; + if (staged > 0) summary += ", staged self-update: " + staged; + log(summary); log("Updated to v" + newVersion); } - private static String getSelfFileName() { - try { - String classPath = Bootstrap.class.getProtectionDomain().getCodeSource().getLocation().toURI().getPath(); - if (classPath != null) { - String fn = Paths.get(classPath).getFileName().toString(); - // If running from a JAR, the exe has the same stem - if (fn.endsWith(".jar")) { - return fn.replace(".jar", ".exe"); - } + private static boolean isSelfExe(String filePath) { + String lower = filePath.toLowerCase().replace("\\", "/"); + return lower.equals("zernmc.exe") || lower.equals("zernmc-cli.exe") + || lower.endsWith("/zernmc.exe") || lower.endsWith("/zernmc-cli.exe"); + } + + /** + * Checks whether any file from the current version's meta is missing + * locally (e.g. a deleted zernmc.exe). Only presence is checked, no + * hashing, so this stays cheap on every launch. + */ + private static boolean hasMissingFiles(String version) { + Map serverFiles = fetchServerMeta(version); + if (serverFiles.isEmpty()) { + log("Warning: Could not fetch meta for repair check"); + return false; + } + for (String filePath : serverFiles.keySet()) { + if (!Files.exists(baseDir.resolve(filePath))) { + log("Missing file: " + filePath); + return true; } - } catch (Exception ignored) {} - return null; + } + return false; + } + + /** + * Applies self-updates that were staged because the target .exe was + * locked (the launcher itself was running). A detached helper waits a + * few seconds for this process to fully exit, then swaps the file. + */ + private static void applyStagedSelfUpdates() { + String os = System.getProperty("os.name", "").toLowerCase(); + if (!os.contains("win") || stagedSelfUpdates.isEmpty()) return; + + for (String filePath : stagedSelfUpdates) { + String targetName = new File(filePath).getName(); + String updateName = targetName + ".update"; + Path updatePath = baseDir.resolve(updateName); + if (!Files.exists(updatePath)) continue; + + Path bat = baseDir.resolve("apply-update-" + System.currentTimeMillis() + ".cmd"); + try { + String content = "@echo off\r\n" + + "ping -n 4 127.0.0.1 > nul\r\n" + + "move /y \"" + updatePath.toAbsolutePath() + "\" \"" + baseDir.resolve(targetName).toAbsolutePath() + "\" > nul\r\n" + + "del /q \"%~f0\"\r\n"; + Files.writeString(bat, content, StandardCharsets.UTF_8); + log("Applying staged self-update after exit: " + targetName); + new ProcessBuilder("cmd.exe", "/c", "start", "", "/min", bat.toAbsolutePath().toString()).start(); + } catch (Exception e) { + log("Warning: Could not stage self-update for " + targetName + " - " + e.getMessage()); + } + } + } + + /** + * Informs the user that a staged self-update was downloaded and needs a + * launcher restart to take effect, then lets the JVM exit so the + * detached helper can swap the .exe. + */ + private static void notifyRestartRequired() { + String message = "Обновление лаунчера загружено.\n" + + "Перезапустите лаунчер для применения изменений в обновлении."; + log("Self-update staged - restart required to apply changes"); + if (!isCliMode && !GraphicsEnvironment.isHeadless()) { + try { + SwingUtilities.invokeAndWait(() -> + JOptionPane.showMessageDialog(null, message, + "ZernMC Launcher", JOptionPane.INFORMATION_MESSAGE)); + } catch (Exception e) { + log("Warning: could not show restart dialog: " + e.getMessage()); + } + } } private static Map fetchServerMeta(String version) { @@ -447,6 +538,10 @@ public class Bootstrap { } private static void downloadFile(String version, String filePath, long expectedSize) throws Exception { + downloadFile(version, filePath, expectedSize, filePath); + } + + private static void downloadFile(String version, String filePath, long expectedSize, String saveAs) throws Exception { List servers = new ArrayList<>(); if (isServerReachable(BASE_URL)) servers.add(BASE_URL); servers.addAll(MIRRORS); @@ -455,17 +550,17 @@ public class Bootstrap { Exception lastError = null; for (String server : servers) { try { - downloadFileFromServer(server + "/launcher/file/" + version + "/" + filePath, expectedSize, filePath); + downloadFileFromServer(server + "/launcher/file/" + version + "/" + filePath, expectedSize, saveAs); return; } catch (Exception e) { lastError = e; } } - downloadFileFromServer(BASE_URL + "/launcher/file/" + version + "/" + filePath, expectedSize, filePath); + downloadFileFromServer(BASE_URL + "/launcher/file/" + version + "/" + filePath, expectedSize, saveAs); } - private static void downloadFileFromServer(String urlStr, long expectedSize, String fileName) throws Exception { + private static void downloadFileFromServer(String urlStr, long expectedSize, String saveAs) throws Exception { URL url = new URL(urlStr); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); conn.setConnectTimeout(10000); @@ -479,13 +574,13 @@ public class Bootstrap { expectedSize = conn.getContentLengthLong(); } - Path outPath = baseDir.resolve(fileName); + Path outPath = baseDir.resolve(saveAs); Files.createDirectories(outPath.getParent()); long downloaded = 0; long lastUpdate = 0; long startTime = System.currentTimeMillis(); - setTitle("Downloading " + fileName); + setTitle("Downloading " + new File(saveAs).getName()); try (InputStream in = conn.getInputStream(); OutputStream out = new FileOutputStream(outPath.toFile())) { diff --git a/launcher/launcher/pom.xml b/launcher/launcher/pom.xml index 6b4c99d..6574adc 100644 --- a/launcher/launcher/pom.xml +++ b/launcher/launcher/pom.xml @@ -99,6 +99,15 @@ + + + src/resources + assets + + ui/** + + + org.apache.maven.plugins diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/launch/LaunchService.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/launch/LaunchService.java index 01969dc..2ffcde7 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/launch/LaunchService.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/api/launch/LaunchService.java @@ -67,6 +67,7 @@ public class LaunchService { LauncherLogger.info("Launching: " + instanceName + " (serverPack=" + instance.isServerPack() + ")"); + long t0 = System.currentTimeMillis(); LaunchCommandBuilder builder = new LaunchCommandBuilder(instance); LaunchOptions options = createOptions(); options.setUsername(AuthManager.getUsername()); @@ -74,9 +75,11 @@ public class LaunchService { options.setUuid(AuthManager.getUuid()); List command = builder.build(options); + LauncherLogger.info("Launch: command built in " + (System.currentTimeMillis() - t0) + " ms"); LauncherLogger.info("Generated command for " + instanceName + ":"); command.forEach(arg -> LauncherLogger.debug(" " + arg)); + long t1 = System.currentTimeMillis(); ProcessBuilder processBuilder = new ProcessBuilder(command); processBuilder.directory(instance.getPath().toFile()); processBuilder.redirectErrorStream(true); @@ -87,6 +90,7 @@ public class LaunchService { Process process = processBuilder.start(); long pid = process.pid(); + LauncherLogger.info("Launch: process started in " + (System.currentTimeMillis() - t1) + " ms (total " + (System.currentTimeMillis() - t0) + " ms), pid=" + pid); runningProcesses.put(pid, process); LauncherLogger.info("Process started, pid=" + pid); @@ -113,10 +117,25 @@ public class LaunchService { logReader.setDaemon(true); logReader.start(); - process.onExit().thenRun(() -> { - runningProcesses.remove(pid); - JFXLauncher.appendGameLog("[Minecraft exited with code: " + process.exitValue() + "]"); - }); + // Watch the child on a DAEMON thread via Process.waitFor() instead of + // process.onExit().thenRun(...): Process.onExit() completes its action on + // the common ForkJoinPool, whose worker threads are non-daemon and keep + // the launcher JVM alive on exit (the v1.0.14.2 "JFX hang (ForkJoinPool)" + // regression, re-introduced in v1.0.14.3's sendBounded). A daemon watcher + // does not prevent JVM shutdown and avoids pinning the common pool. + Thread processWatcher = new Thread(() -> { + try { + int exitCode = process.waitFor(); + runningProcesses.remove(pid); + JFXLauncher.appendGameLog("[Minecraft exited with code: " + exitCode + "]"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (Exception e) { + JFXLauncher.appendGameLog("[Error watching process: " + e.getMessage() + "]"); + } + }, "ProcessWatcher-" + instanceName); + processWatcher.setDaemon(true); + processWatcher.start(); ProcessInfo info = new ProcessInfo(instanceName, pid, "RUNNING"); return ApiResponse.success(info); diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilder.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilder.java index a1cb42d..dacbd91 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilder.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilder.java @@ -64,35 +64,49 @@ public class LaunchCommandBuilder { // instead of "_1._20._1" (automatic module from filename 1.20.1.jar). ensureVersionJarForForge(); - // Build classpath from manifest libraries + client jar - String classpath; - if (manifest != null) { - classpath = buildClasspathFromManifest(manifest, false); - } else { - classpath = ""; - } - - // Add client jar to classpath (first position) - Path clientJar = findVersionJar(); - if (clientJar != null && isValidJar(clientJar)) { - String sep = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":"; - classpath = clientJar.toAbsolutePath().toString() + (classpath.isEmpty() ? "" : sep + classpath); - System.out.println(ZAnsi.green(" Added client jar: " + clientJar.getFileName())); - } else { - System.out.println(ZAnsi.yellow(" Client jar not found, falling back to vanilla classpath")); - classpath = buildVanillaClasspath(); - } - - String cpFile = writeClasspathFile(classpath); - - // Build variable map for placeholder substitution - Map vars = buildVariableMap(options); - vars.put("classpath", cpFile); - - // Parse version.json JVM args (child only). Forge/NeoForge defines its own - // -p, --add-modules, --add-opens. -Djava.library.path is NOT in child args, - // so it's added manually below. + // Parse version.json JVM args (child only) and game args (merged with parent). + // Forge/NeoForge defines its own -p, --add-modules, --add-opens. + // -Djava.library.path is NOT in child args, so it's added manually below. List allJvmArgs = manifest != null ? manifest.getJvmArguments() : new ArrayList<>(); + List allGameArgs = manifest != null ? manifest.getAllGameArguments() : new ArrayList<>(); + + // Build variable map for placeholder substitution (${classpath}, + // ${natives_directory}, ${library_directory}, ${version_name}, ...) + Map vars = buildVariableMap(options); + + // Forge/NeoForge version.json defines its own -p / --module-path / + // --add-modules / --add-opens and references ${classpath}. We substitute + // ${classpath} once (only when referenced) and let the version.json + // arguments drive the classpath/module path exactly as AstralRinth and the + // 1.0.13 launcher did. Do NOT inject a separate -DlegacyClassPath: + // modern Forge's BootstrapLauncher derives the MC-BOOTSTRAP layer from the + // module path, and a redundant -DlegacyClassPath duplicates every library + // and can trigger split-package / duplicate-entry failures. + boolean needsClasspath = referencesClasspath(allJvmArgs) || referencesClasspath(allGameArgs); + if (needsClasspath) { + String classpath; + if (manifest != null) { + classpath = buildClasspathFromManifest(manifest, false); + } else { + classpath = ""; + } + + Path clientJar = findVersionJar(); + if (clientJar != null && isValidJar(clientJar)) { + String sep = System.getProperty("os.name").toLowerCase().contains("win") ? ";" : ":"; + classpath = clientJar.toAbsolutePath().toString() + (classpath.isEmpty() ? "" : sep + classpath); + System.out.println(ZAnsi.green(" Added client jar: " + clientJar.getFileName())); + } else { + System.out.println(ZAnsi.yellow(" Client jar not found, falling back to vanilla classpath")); + classpath = buildVanillaClasspath(); + } + + vars.put("classpath", writeClasspathFile(classpath)); + System.out.println(ZAnsi.green(" ${classpath} referenced, classpath built for substitution")); + } else { + System.out.println(ZAnsi.cyan(" ${classpath} not referenced by version.json, skipping classpath build")); + } + if (!allJvmArgs.isEmpty()) { for (String arg : allJvmArgs) { String resolved = resolveVariable(arg, vars); @@ -141,7 +155,6 @@ public class LaunchCommandBuilder { // Parse version.json game args (merged with parent) with placeholder // substitution. Parent provides --username, --version, etc.; // child provides --launchTarget, --fml.*. - List allGameArgs = manifest != null ? manifest.getAllGameArguments() : new ArrayList<>(); if (!allGameArgs.isEmpty()) { for (String arg : allGameArgs) { String resolved = resolveVariable(arg, vars); @@ -503,7 +516,7 @@ public class LaunchCommandBuilder { Path nativesDir = gameDir.resolve("natives"); Path librariesDir = gameDir.resolve("libraries"); - vars.put("version_name", instance.getName()); + vars.put("version_name", getVersionId()); vars.put("game_directory", gameDir.toString()); vars.put("assets_root", assetsDir.toString()); vars.put("assets_index_name", instance.getAssetIndex() != null ? instance.getAssetIndex() : instance.getMinecraftVersion()); @@ -543,6 +556,14 @@ public class LaunchCommandBuilder { return result; } + private boolean referencesClasspath(List args) { + if (args == null) return false; + for (String arg : args) { + if (arg != null && arg.contains("${classpath}")) return true; + } + return false; + } + private String buildClasspathFromManifest(VersionManifest manifest, boolean includeVersionJar) throws Exception { List paths = new ArrayList<>(); Path librariesDir = instance.getPath().resolve("libraries"); diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java index 31aa142..ca7ca7d 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/ui/jfx/JFXLauncher.java @@ -186,7 +186,7 @@ public class JFXLauncher extends Application { public static void main(String[] args) { Runtime.getRuntime().addShutdownHook(new Thread(() -> { - System.out.println("[JFX] Shutdown hook triggered"); + LauncherLogger.info("[JFX] Shutdown signal received..."); LaunchService.killAllProcesses(); })); launch(args); @@ -348,14 +348,16 @@ public class JFXLauncher extends Application { log("No saved session found, will show login screen"); } log("Starting background network init..."); - new Thread(() -> { + Thread netInitThread = new Thread(() -> { try { ZHttpClient.checkAllServicesOnStartup(); log("Network init complete"); } catch (Exception e) { log("Network init warning: " + e.getMessage()); } - }, "network-init").start(); + }, "network-init"); + netInitThread.setDaemon(true); + netInitThread.start(); startServer(); WebView webView = new WebView(); @@ -441,11 +443,17 @@ public class JFXLauncher extends Application { log("Window displayed"); stage.setOnCloseRequest(e -> { - log("Closing..."); - LaunchService.killAllProcesses(); - if (server != null) server.stop(0); + log("Shutdown signal received..."); + shutdown(); Platform.exit(); - System.exit(0); + Thread watchdog = new Thread(() -> { + try { + Thread.sleep(2000); + } catch (InterruptedException ignored) {} + System.exit(0); + }, "force-exit"); + watchdog.setDaemon(true); + watchdog.start(); }); } catch (Exception e) { @@ -508,7 +516,11 @@ public class JFXLauncher extends Application { server.createContext("/api/admin", this::handleAdmin); server.createContext("/assets/", this::handleStatic); - server.setExecutor(Executors.newCachedThreadPool()); + server.setExecutor(Executors.newCachedThreadPool(r -> { + Thread t = new Thread(r, "http-server-" + server.getAddress().getPort()); + t.setDaemon(true); + return t; + })); server.start(); log("HTTP server on port " + PORT); @@ -814,6 +826,7 @@ public class JFXLauncher extends Application { setInstallProgressWithStage("Error: " + e.getMessage(), 0, 100, "Error", 0, 1); } }); + installThread.setDaemon(true); installThread.start(); } else { sendJson(exchange, Map.of("success", false, "error", "Instance not found")); @@ -1194,28 +1207,43 @@ public class JFXLauncher extends Application { private void handleShutdown(HttpExchange exchange) { log("Shutdown request received..."); - LaunchService.killAllProcesses(); - if (server != null) server.stop(0); - Platform.exit(); + shutdown(); System.exit(0); } private void handleExit(HttpExchange exchange) { log("Exiting..."); - LaunchService.killAllProcesses(); - if (mainStage != null) mainStage.close(); - Platform.exit(); + shutdown(); System.exit(0); } private void handleExitParent(HttpExchange exchange) { log("Terminating parent process..."); - LaunchService.killAllProcesses(); - if (mainStage != null) mainStage.close(); - Platform.exit(); + shutdown(); System.exit(240); } + /** + * Releases launcher resources on shutdown: kills any running game + * processes and stops the embedded HTTP server. + */ + private void shutdown() { + LaunchService.killAllProcesses(); + if (server != null) { + server.stop(0); + java.util.concurrent.ExecutorService exec = + (java.util.concurrent.ExecutorService) server.getExecutor(); + if (exec != null) exec.shutdownNow(); + } + } + + @Override + public void stop() { + LauncherLogger.info("[JFX] Application stop() called, releasing resources..."); + shutdown(); + LauncherLogger.info("[JFX] Clean shutdown complete"); + } + private void handleStatic(HttpExchange exchange) { try { String path = exchange.getRequestURI().getPath(); @@ -1232,15 +1260,31 @@ public class JFXLauncher extends Application { return; } - if (!Files.exists(file) || Files.isDirectory(file)) { - log("[UI] File not found: " + file); + byte[] content = null; + String source = null; + + if (Files.exists(file) && !Files.isDirectory(file)) { + content = Files.readAllBytes(file); + source = "disk"; + } else { + // Fallback to the UI bundled inside the launcher jar, so the + // UI never breaks when the on-disk assets are missing/stale. + try (InputStream in = getClass().getResourceAsStream("/" + relativePath)) { + if (in != null) { + content = in.readAllBytes(); + source = "jar"; + } + } + } + + if (content == null) { + log("[UI] File not found: " + file + " (not bundled in jar either)"); exchange.sendResponseHeaders(404, 0); exchange.close(); return; } - byte[] content = Files.readAllBytes(file); - log("[UI] Loaded " + content.length + " bytes: " + path); + log("[UI] Loaded " + content.length + " bytes from " + source + ": " + path); String ct = getContentType(path); if (ct.startsWith("text/")) { diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ZHttpClient.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ZHttpClient.java index 9761aa7..45f866f 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ZHttpClient.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/utils/ZHttpClient.java @@ -20,8 +20,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Stream; @@ -30,7 +28,6 @@ public class ZHttpClient { private static final HttpClient client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(15)) .version(HttpClient.Version.HTTP_1_1) - .executor(Executors.newCachedThreadPool()) .build(); private static String BASE_URL = "https://api.zernmc.ru"; @@ -303,20 +300,19 @@ public class ZHttpClient { } } + /** + * Synchronous HTTP send with a hard timeout. Uses client.send() (blocking on the + * calling thread) rather than sendAsync(...).get(...) — the latter submits work + * to the HttpClient's executor and, when that was a non-daemon CachedThreadPool, + * kept the JVM alive / could starve the common ForkJoinPool (the v1.0.14.2 + * "JFX hang (ForkJoinPool)" regression, re-introduced in v1.0.14.3). + * The request-level .timeout(Duration) bounds the actual network I/O. + */ private static HttpResponse sendBounded(HttpRequest request, HttpResponse.BodyHandler handler, long timeoutSeconds) throws IOException, InterruptedException { - try { - return client.sendAsync(request, handler).get(timeoutSeconds, TimeUnit.SECONDS); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw e; - } catch (java.util.concurrent.ExecutionException e) { - Throwable cause = e.getCause() != null ? e.getCause() : e; - if (cause instanceof IOException) throw (IOException) cause; - if (cause instanceof InterruptedException) throw (InterruptedException) cause; - throw new IOException(cause); - } catch (java.util.concurrent.TimeoutException e) { - throw new IOException("Request timed out after " + timeoutSeconds + "s", e); - } + HttpRequest timed = request.newBuilder() + .timeout(Duration.ofSeconds(timeoutSeconds)) + .build(); + return client.send(timed, handler); } public static String getWithSmartProxy(String url) throws IOException, InterruptedException { diff --git a/launcher/launcher/src/resources/ui/launcher.js b/launcher/launcher/src/resources/ui/launcher.js index 54a5957..7c4ade3 100644 --- a/launcher/launcher/src/resources/ui/launcher.js +++ b/launcher/launcher/src/resources/ui/launcher.js @@ -495,7 +495,8 @@ class ZernMCLauncher { } if (typeof fetch !== 'undefined') { var controller = new AbortController(); - var timeoutId = setTimeout(function() { controller.abort(); }, 15000); + var timeoutMs = opts.timeout || 15000; + var timeoutId = setTimeout(function() { controller.abort(); }, timeoutMs); try { var r = await fetch(API + endpoint, { method: opts.method || 'GET', @@ -522,7 +523,7 @@ class ZernMCLauncher { }; xhr.onerror = function() { resolve({ success: false, error: 'Network error' }); }; xhr.ontimeout = function() { resolve({ success: false, error: 'Request timeout' }); }; - xhr.timeout = 30000; + xhr.timeout = opts.timeout || 30000; xhr.send(opts.body || null); }); } catch (e) { @@ -1545,7 +1546,7 @@ class ZernMCLauncher { return; } this.toast(tr('toast.launching', null, {name: inst.name}), 'info'); - const r = await this.req('/launch', { method: 'POST', body: JSON.stringify({ name: inst.name }) }); + const r = await this.req('/launch', { method: 'POST', body: JSON.stringify({ name: inst.name }), timeout: 120000 }); if (r.success) { this.toast(tr('toast.launched', null, {pid: String(r.data?.pid || '')}), 'success'); this.startPlaytimeTracking(inst.name); @@ -1558,7 +1559,7 @@ class ZernMCLauncher { const inst = this.state.selectedPack; if (!inst) return; this.setPlayBtnText('playBar.updating', true); - const r = await this.req('/update', { method: 'POST', body: JSON.stringify({ name: inst.name }) }); + const r = await this.req('/update', { method: 'POST', body: JSON.stringify({ name: inst.name }), timeout: 120000 }); if (r.success) { this.toast(tr('toast.updated', null, {name: inst.name, version: String(r.data?.version || '')}), 'success'); if (this.state.updateMap) { diff --git a/launcher/launcher/src/test/java/me/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilderTest.java b/launcher/launcher/src/test/java/me/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilderTest.java index c7d24fb..b039f23 100644 --- a/launcher/launcher/src/test/java/me/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilderTest.java +++ b/launcher/launcher/src/test/java/me/sashegdev/zernmc/launcher/minecraft/launch/LaunchCommandBuilderTest.java @@ -272,6 +272,122 @@ class LaunchCommandBuilderTest { "Classpath must include vanilla client library"); } + @Test + void forge_noClasspathRef_buildsLegacyClassPath() throws Exception { + // Regression guard for the 1.0.14.x regression: the launcher must NOT inject the + // self-invented `-DlegacyClassPath` flag. Modern Forge/NeoForge derive the MC-BOOTSTRAP + // module layer from the module path / modlauncher classpath (the version.json `-p + // ${classpath}` placeholder, resolved by the launcher), NOT from `-DlegacyClassPath` + // (a 1.12-era mechanism that, when duplicated here, causes split-package / duplicate- + // entry failures and prevents the game from starting). This matches the 1.0.13 + // behaviour and AstralRinth's real Forge launch, where the classpath is supplied + // through `${classpath}` substitution only. + String mcVer = "1.20.1"; + String forgeVer = "47.3.0"; + String versionId = mcVer + "-forge-" + forgeVer; + + String vanillaJson = """ + { + "id": "1.20.1", + "mainClass": "net.minecraft.client.main.Main", + "arguments": {"game": ["--username", "${auth_player_name}"]}, + "assetIndex": {"id": "1.20.1"}, + "libraries": [ + {"name": "net.minecraft:client:1.20.1", "downloads": {"artifact": {"path": "net/minecraft/client/1.20.1/client-1.20.1.jar"}}}, + {"name": "com.google.guava:guava:31.1-jre", "downloads": {"artifact": {"path": "com/google/guava/guava/31.1-jre/guava-31.1-jre.jar"}}} + ] + } + """; + writeVersionJson(tempDir.resolve("versions/1.20.1/1.20.1.json"), vanillaJson); + createJar(tempDir.resolve("versions/1.20.1/1.20.1.jar")); + createJar(tempDir.resolve("libraries/net/minecraft/client/1.20.1/client-1.20.1.jar")); + createJar(tempDir.resolve("libraries/com/google/guava/guava/31.1-jre/guava-31.1-jre.jar")); + + // Realistic Forge child: references ${classpath} (serves as module path + classpath, + // exactly like AstralRinth / 1.0.13), plus the standard Forge bootstrap args. + String forgeJson = """ + { + "id": "1.20.1-forge-47.3.0", + "inheritsFrom": "1.20.1", + "mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher", + "arguments": { + "jvm": ["-DlibraryDirectory=${library_directory}", + "-DignoreList=bootstraplauncher,forge-,${version_name}.jar", + "-p", "${classpath}", + "--add-modules", "ALL-MODULE-PATH"], + "game": ["--launchTarget", "forgeclient", "--fml.forgeVersion", "47.3.0", + "--fml.mcVersion", "1.20.1", "--fml.forgeGroup", "net.minecraftforge"] + }, + "libraries": [ + {"name": "cpw.mods:modlauncher:10.0.9", "downloads": {"artifact": {"path": "cpw/mods/modlauncher/10.0.9/modlauncher-10.0.9.jar"}}}, + {"name": "net.minecraftforge:fmlloader:1.20.1-47.3.0", "downloads": {"artifact": {"path": "net/minecraftforge/fmlloader/1.20.1-47.3.0/fmlloader-1.20.1-47.3.0.jar"}}} + ] + } + """; + writeVersionJson(tempDir.resolve("versions/" + versionId + "/" + versionId + ".json"), forgeJson); + Files.createDirectories(tempDir.resolve("natives")); + createJar(tempDir.resolve("libraries/cpw/mods/modlauncher/10.0.9/modlauncher-10.0.9.jar")); + createJar(tempDir.resolve("libraries/net/minecraftforge/fmlloader/1.20.1-47.3.0/fmlloader-1.20.1-47.3.0.jar")); + + Instance instance = new Instance("test-forge-realjson", tempDir); + instance.setMinecraftVersion(mcVer); + instance.setLoaderType("forge"); + instance.setLoaderVersion(forgeVer); + instance.setAssetIndex(mcVer); + + LaunchOptions options = new LaunchOptions(); + List command = new LaunchCommandBuilder(instance).build(options); + + // No self-invented -DlegacyClassPath must ever be emitted for Forge. + assertFalse(command.stream().anyMatch(a -> a != null && a.startsWith("-DlegacyClassPath=")), + "Forge must NOT have a -DlegacyClassPath (1.0.13/AstralRinth rely on ${classpath})"); + + // ${classpath} must have been substituted into the -p module path, carrying all libs. + int pIdx = command.indexOf("-p"); + assertTrue(pIdx >= 0, "Forge must have -p"); + assertTrue(pIdx + 1 < command.size(), "-p must have a value"); + String modulePath = command.get(pIdx + 1); + assertNotNull(modulePath, "-p value must not be null"); + assertTrue(modulePath.contains("modlauncher-10.0.9.jar"), + "-p (resolved classpath) must contain modlauncher, got: " + modulePath); + assertTrue(modulePath.contains("fmlloader-1.20.1-47.3.0.jar"), + "-p (resolved classpath) must contain forge libraries, got: " + modulePath); + + // version_name resolves to the version id (not the instance name) in -DignoreList. + String ignoreList = null; + for (String arg : command) { + if (arg != null && arg.startsWith("-DignoreList=")) ignoreList = arg; + } + assertNotNull(ignoreList, "Must have -DignoreList"); + assertTrue(ignoreList.contains("1.20.1-forge-47.3.0.jar"), + "-DignoreList must use version id, got: " + ignoreList); + + // --add-modules ALL-MODULE-PATH as two args (forge format) + int amIdx = command.indexOf("--add-modules"); + assertTrue(amIdx >= 0, "Must have --add-modules"); + assertEquals("ALL-MODULE-PATH", command.get(amIdx + 1)); + + // Main class + game args + assertTrue(command.contains("cpw.mods.bootstraplauncher.BootstrapLauncher")); + assertTrue(command.contains("--launchTarget")); + } + + @Test + void forge_versionNameIsVersionIdNotInstanceName() throws Exception { + Instance instance = createForgeFixture(tempDir); + LaunchOptions options = new LaunchOptions(); + List command = new LaunchCommandBuilder(instance).build(options); + + String ignoreList = null; + for (String arg : command) { + if (arg != null && arg.startsWith("-DignoreList=")) ignoreList = arg; + } + if (ignoreList != null) { + assertTrue(ignoreList.contains("1.20.1-forge-47.3.0.jar"), + "-DignoreList must use version id, got: " + ignoreList); + } + } + // ================================================================ // NeoForge tests // ================================================================ diff --git a/launcher/pom.xml b/launcher/pom.xml index ac46ac1..ec86eea 100644 --- a/launcher/pom.xml +++ b/launcher/pom.xml @@ -18,8 +18,8 @@ - 1.0.14 - 5 + 1.0.15 + 0 21 21 UTF-8