From ecb49e1eb775150c1739d41d2784eb93fbed0d0a Mon Sep 17 00:00:00 2001 From: SashegDev Date: Sat, 15 Aug 2026 09:31:23 +0000 Subject: [PATCH] =?UTF-8?q?v1.0.16.3=20=E2=80=94=20fix=20install=20hang=20?= =?UTF-8?q?at=200%=20(JDK=20request=20timeout=20not=20enforced=20on=20stal?= =?UTF-8?q?led=20body,=20cancel=20via=20sendAsync;=20proxy=20fallback=20on?= =?UTF-8?q?=20timeout),=20verify=20client.jar=20integrity,=20mod=20install?= =?UTF-8?q?=20via=20smart=20proxy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../minecraft/installer/VersionInstaller.java | 18 +++++- .../zernmc/launcher/ui/jfx/JFXLauncher.java | 18 +----- .../zernmc/launcher/utils/ZHttpClient.java | 56 ++++++++++++++++--- launcher/pom.xml | 2 +- 4 files changed, 70 insertions(+), 24 deletions(-) diff --git a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/VersionInstaller.java b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/VersionInstaller.java index 5065cbc..311ae21 100644 --- a/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/VersionInstaller.java +++ b/launcher/launcher/src/main/java/sashegdev/zernmc/launcher/minecraft/installer/VersionInstaller.java @@ -64,7 +64,8 @@ public class VersionInstaller { Files.createDirectories(versionDir); boolean alreadyInstalled = Files.exists(versionDir.resolve(versionId + ".json")) - && Files.exists(versionDir.resolve(versionId + ".jar")); + && Files.exists(versionDir.resolve(versionId + ".jar")) + && isValidJar(versionDir.resolve(versionId + ".jar")); if (alreadyInstalled) { LauncherLogger.info("Minecraft " + versionId + " already installed, skipping"); String existing = Files.readString(versionDir.resolve(versionId + ".json")); @@ -73,6 +74,13 @@ public class VersionInstaller { ? existingData.getJSONObject("assetIndex").getString("id") : existingData.getString("assets"); } + // A stale/partial client.jar (e.g. aborted by the download timeout) must be + // removed, otherwise it would be skipped as "already installed" and stay corrupt. + try { + Files.deleteIfExists(versionDir.resolve(versionId + ".jar")); + } catch (IOException e) { + LauncherLogger.warn("Could not remove stale client.jar: " + e.getMessage()); + } ProgressBar.show("Fetching version info", 0, 1, "files"); String versionJson; @@ -337,6 +345,14 @@ public class VersionInstaller { } } + private boolean isValidJar(Path path) { + try (java.util.zip.ZipFile zf = new java.util.zip.ZipFile(path.toFile())) { + return true; + } catch (Exception e) { + return false; + } + } + public String getAssetIndexId(String versionId) throws Exception { JSONObject versionData = ZHttpClient.getMojangVersionJson(versionId); 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 680fd15..b52d8ba 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 @@ -1624,22 +1624,10 @@ public class JFXLauncher extends Application { Files.createDirectories(modsDir); Path targetFile = modsDir.resolve(modName); - // Download mod from server + // Download mod from server (through ZHttpClient so the read timeout is + // actually enforced — raw client.send() hangs forever on a stalled body) String url = ZHttpClient.getBaseUrl() + "/whitelist/mods/" + modHash + "/" + modName; - java.net.http.HttpRequest request = java.net.http.HttpRequest.newBuilder() - .uri(java.net.URI.create(url)) - .timeout(java.time.Duration.ofMinutes(5)) - .header("Authorization", "Bearer " + AuthManager.getAccessToken()) - .GET() - .build(); - java.net.http.HttpResponse resp = java.net.http.HttpClient.newHttpClient() - .send(request, java.net.http.HttpResponse.BodyHandlers.ofFile(targetFile)); - - if (resp.statusCode() != 200) { - Files.deleteIfExists(targetFile); - sendJson(exchange, Map.of("success", false, "error", "HTTP " + resp.statusCode())); - return; - } + ZHttpClient.downloadFileWithSmartProxy(url, targetFile); sendJson(exchange, Map.of("success", true, "message", modName + " installed")); } catch (Exception e) { 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 e50108b..bc19b58 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 @@ -11,6 +11,7 @@ import java.net.URLEncoder; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -19,15 +20,33 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Stream; public class ZHttpClient { + /** + * Dedicated daemon executor for the shared HttpClient. sendAsync() work runs + * here (never the common ForkJoinPool, so a stalled download cannot starve + * JFX's pools), and daemon threads never keep the launcher JVM alive. + */ + private static final ExecutorService CLIENT_EXECUTOR = Executors.newCachedThreadPool(r -> { + Thread t = new Thread(r, "zern-http-client"); + t.setDaemon(true); + return t; + }); + private static final HttpClient client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(15)) .version(HttpClient.Version.HTTP_1_1) + .executor(CLIENT_EXECUTOR) .build(); private static String BASE_URL = "https://api.zernmc.ru"; @@ -301,18 +320,41 @@ 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. + * HTTP send with a hard, actually-enforced timeout. + * + * NB: HttpRequest.timeout() alone is NOT reliable for streaming body handlers + * (BodyHandlers.ofFile/ofString): the JDK cancels the request timer as soon as + * response headers arrive, so a download that stalls mid-body blocks + * client.send() forever (no exception, no retry, no proxy fallback — the + * exact "install hangs at 0%" bug). Therefore we drive the exchange through + * sendAsync() + future.get(timeout) and abort it with cancel() on timeout. + * + * The client runs on a dedicated daemon executor (see CLIENT_EXECUTOR), so + * this does not reintroduce the v1.0.14.2 JFX hang (ForkJoinPool starvation / + * non-daemon executor keeping the JVM alive) that forced the old sync-send + * implementation. */ private static HttpResponse sendBounded(HttpRequest request, HttpResponse.BodyHandler handler, long timeoutSeconds) throws IOException, InterruptedException { HttpRequest timed = HttpRequest.newBuilder(request, (name, value) -> true) .timeout(Duration.ofSeconds(timeoutSeconds)) .build(); - return client.send(timed, handler); + CompletableFuture> future = client.sendAsync(timed, handler); + try { + return future.get(timeoutSeconds, TimeUnit.SECONDS); + } catch (TimeoutException e) { + future.cancel(true); + throw new HttpTimeoutException("Response not received within " + timeoutSeconds + "s: " + timed.uri()); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof IOException) throw (IOException) cause; + if (cause instanceof InterruptedException) throw (InterruptedException) cause; + if (cause instanceof RuntimeException) throw (RuntimeException) cause; + throw new IOException("HTTP request failed: " + timed.uri(), cause); + } catch (InterruptedException e) { + future.cancel(true); + Thread.currentThread().interrupt(); + throw e; + } } public static String getWithSmartProxy(String url) throws IOException, InterruptedException { diff --git a/launcher/pom.xml b/launcher/pom.xml index 557c35c..0cbfdf8 100644 --- a/launcher/pom.xml +++ b/launcher/pom.xml @@ -19,7 +19,7 @@ 1.0.16 - 2 + 3 21 21 UTF-8