Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,11 @@ public static void initialize(Stage stage) {
.build());
}

if (HotSpotCrashDetector.hasPreviousCrash()) {
Controllers.dialogLater(new MessageDialogPane.Builder(
i18n("launcher.crash.previous_run"), null, MessageType.WARNING).ok(null).build());
}

if (SettingsManager.userState().agreementVersionProperty().get() < 1) {
JFXDialogLayout agreementPane = new JFXDialogLayout();
agreementPane.setHeading(new Label(i18n("launcher.agreement")));
Expand Down
228 changes: 228 additions & 0 deletions HMCL/src/main/java/org/jackhuang/hmcl/util/HotSpotCrashDetector.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.util;

import org.jackhuang.hmcl.util.io.IOUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;

import java.io.BufferedReader;
import java.io.IOException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import static org.jackhuang.hmcl.Metadata.CURRENT_DIRECTORY;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;

/// Detects a HotSpot error report associated with the previous HMCL session.
@NotNullByDefault
public final class HotSpotCrashDetector {
/// Matches HotSpot error report file names and captures their process IDs.
private static final Pattern ERROR_FILE_PATTERN = Pattern.compile("hs_err_pid(?<pid>\\d+)\\.log");

/// Matches uncompressed HMCL log file names and captures their start times.
private static final Pattern LOG_FILE_PATTERN = Pattern.compile("(?<time>\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2})(?:\\.\\d+)?\\.log");

/// Matches the process ID recorded in a HotSpot error report.
private static final Pattern ERROR_PID_PATTERN = Pattern.compile("^#.*\\bpid=(?<pid>\\d+),.*$");

/// Matches the report time and elapsed process time recorded by HotSpot.
private static final Pattern ERROR_TIME_PATTERN = Pattern.compile(
"^Time: \\w{3} (?<month>\\w{3})\\s+(?<day>\\d{1,2}) (?<clock>\\d{2}:\\d{2}:\\d{2}) (?<year>\\d{4})(?: \\S+)? elapsed time: (?<elapsed>\\d+(?:\\.\\d+)?) seconds.*$");

/// Parses timestamps used in HMCL log file names.
private static final DateTimeFormatter LOG_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss");

/// Parses timestamps used in HotSpot error reports.
private static final DateTimeFormatter ERROR_TIME_FORMATTER = DateTimeFormatter.ofPattern("MMM d HH:mm:ss yyyy", Locale.ENGLISH);

/// Allows for the delay between JVM startup and logger initialization.
private static final Duration START_TIME_TOLERANCE = Duration.ofSeconds(5);

/// Prevents instantiation of this utility class.
private HotSpotCrashDetector() {
}

/// Returns whether the previous HMCL session produced a matching HotSpot error report.
public static boolean hasPreviousCrash() {
List<Path> reports = findErrorReports();
if (reports.isEmpty())
return false;

List<Path> recentLogs = LOG.findRecentLogFiles(1);
if (recentLogs.isEmpty())
return false;

Path previousLog = recentLogs.get(0);
@Nullable LocalDateTime logStartTime = parseLogStartTime(previousLog);
if (logStartTime == null)
return false;

@Nullable String launcherName;
try {
launcherName = readLauncherName(previousLog);
} catch (IOException e) {
LOG.warning("Failed to read previous HMCL log " + previousLog, e);
return false;
}
if (launcherName == null)
return false;

for (Path report : reports) {
try {
if (matches(report, logStartTime, launcherName))
return true;
} catch (IOException e) {
LOG.warning("Failed to read HotSpot error report " + report, e);
}
}
return false;
}

/// Finds HotSpot error reports in the current working directory.
private static List<Path> findErrorReports() {
List<Path> reports = new ArrayList<>();
try (DirectoryStream<Path> stream = Files.newDirectoryStream(CURRENT_DIRECTORY, "hs_err_pid*.log")) {
for (Path path : stream) {
if (Files.isRegularFile(path) && ERROR_FILE_PATTERN.matcher(path.getFileName().toString()).matches())
reports.add(path);
}
} catch (IOException e) {
LOG.warning("Failed to find HotSpot error reports in " + CURRENT_DIRECTORY, e);
}
return reports;
}

/// Parses the start time from an uncompressed HMCL log file name.
private static @Nullable LocalDateTime parseLogStartTime(Path logFile) {
Matcher matcher = LOG_FILE_PATTERN.matcher(logFile.getFileName().toString());
if (!matcher.matches())
return null;

try {
return LocalDateTime.parse(matcher.group("time"), LOG_TIME_FORMATTER);
} catch (DateTimeParseException e) {
return null;
}
}

/// Returns the launcher file name when the log belongs to HMCL in the current directory.
private static @Nullable String readLauncherName(Path logFile) throws IOException {
boolean hmclLog = false;
boolean sameDirectory = false;
@Nullable String launcherName = null;

try (BufferedReader reader = Files.newBufferedReader(logFile)) {
@Nullable String line;
while ((line = reader.readLine()) != null) {
if (line.contains("*** HMCL ")) {
hmclLog = true;
} else if (line.contains("Current Directory: ")) {
String value = line.substring(line.indexOf("Current Directory: ") + "Current Directory: ".length());
try {
sameDirectory = CURRENT_DIRECTORY.equals(Path.of(value).toAbsolutePath().normalize());
} catch (InvalidPathException ignored) {
return null;
}
} else if (line.contains("HMCL Jar Path: ")) {
String value = line.substring(line.indexOf("HMCL Jar Path: ") + "HMCL Jar Path: ".length());
try {
@Nullable Path fileName = Path.of(value).getFileName();
launcherName = fileName == null ? null : fileName.toString();
} catch (InvalidPathException ignored) {
return null;
}
}

if (hmclLog && sameDirectory && launcherName != null)
return launcherName;
}
}
return null;
}

/// Returns whether a HotSpot error report matches the previous HMCL session.
private static boolean matches(Path report, LocalDateTime logStartTime, String launcherName) throws IOException {
Matcher fileMatcher = ERROR_FILE_PATTERN.matcher(report.getFileName().toString());
if (!fileMatcher.matches())
return false;

String filePid = fileMatcher.group("pid");
boolean fatalError = false;
@Nullable String reportPid = null;
@Nullable String commandLine = null;
@Nullable LocalDateTime processStartTime = null;

try (BufferedReader reader = IOUtils.newBufferedReaderMaybeNativeEncoding(report)) {
@Nullable String line;
while ((line = reader.readLine()) != null) {
if (line.equals("# A fatal error has been detected by the Java Runtime Environment:")) {
fatalError = true;
continue;
}

Matcher pidMatcher = ERROR_PID_PATTERN.matcher(line);
if (pidMatcher.matches()) {
reportPid = pidMatcher.group("pid");
continue;
}

if (line.startsWith("Command Line: ")) {
commandLine = line.substring("Command Line: ".length());
continue;
}

Matcher timeMatcher = ERROR_TIME_PATTERN.matcher(line);
if (timeMatcher.matches()) {
try {
LocalDateTime reportTime = LocalDateTime.parse(
timeMatcher.group("month") + " " + timeMatcher.group("day") + " "
+ timeMatcher.group("clock") + " " + timeMatcher.group("year"),
ERROR_TIME_FORMATTER);
double elapsedSeconds = Double.parseDouble(timeMatcher.group("elapsed"));
if (elapsedSeconds > Long.MAX_VALUE / 1000.0)
return false;
processStartTime = reportTime.minus(Duration.ofMillis(Math.round(elapsedSeconds * 1000)));
} catch (DateTimeParseException | NumberFormatException e) {
return false;
}
}

if (line.startsWith("--------------- T H R E A D"))
break;
}
}

return fatalError
&& filePid.equals(reportPid)
&& commandLine != null && commandLine.contains(launcherName)
&& processStartTime != null
&& Duration.between(logStartTime, processStartTime).abs().compareTo(START_TIME_TOLERANCE) <= 0;
}
}
1 change: 1 addition & 0 deletions HMCL/src/main/resources/assets/lang/I18N.properties
Original file line number Diff line number Diff line change
Expand Up @@ -987,6 +987,7 @@ launcher.contact=Contact Us
launcher.crash=Hello Minecraft! Launcher has encountered a fatal error! Please copy the following log and ask for help on our Discord, QQ group, GitHub, or other Minecraft forum.
launcher.crash.java_internal_error=Hello Minecraft! Launcher has encountered a fatal error because your Java is corrupted. Please uninstall your Java and download a suitable Java <a href="https://bell-sw.com/pages/downloads/#downloads">here</a>.
launcher.crash.hmcl_out_dated=Hello Minecraft! Launcher has encountered a fatal error! Your launcher is outdated. Please update your launcher!
launcher.crash.previous_run=HMCL may have encountered a Java Virtual Machine crash during its previous run. If this warning appears repeatedly, you can click the Help button in the upper-right corner to ask for help.
launcher.update_java=Please update your Java version.

libraries.download=Downloading Libraries
Expand Down
1 change: 1 addition & 0 deletions HMCL/src/main/resources/assets/lang/I18N_zh.properties
Original file line number Diff line number Diff line change
Expand Up @@ -796,6 +796,7 @@ launcher.contact=聯絡我們
launcher.crash=Hello Minecraft! Launcher 遇到了無法處理的錯誤。請複製下列內容並透過 GitHub、Discord 或 HMCL QQ 群回報問題。
launcher.crash.java_internal_error=Hello Minecraft! Launcher 由於目前 Java 損壞而無法繼續執行。請移除目前 Java,點擊 <a href="https://bell-sw.com/pages/downloads/#downloads">此處</a> 安裝合適的 Java 版本。
launcher.crash.hmcl_out_dated=Hello Minecraft! Launcher 遇到了無法處理的錯誤。已偵測到你的啟動器不是最新版本,請更新後重試!
launcher.crash.previous_run=HMCL 上次執行時可能發生了 Java 虛擬機崩潰。若此提示反覆出現,你可以點擊右上角幫助按鈕進行求助。
launcher.update_java=請更新你的 Java

libraries.download=下載依賴庫
Expand Down
1 change: 1 addition & 0 deletions HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties
Original file line number Diff line number Diff line change
Expand Up @@ -802,6 +802,7 @@ launcher.contact=联系我们
launcher.crash=Hello Minecraft! Launcher 遇到了无法处理的错误。请复制下列内容并点击右下角的按钮反馈问题。
launcher.crash.java_internal_error=Hello Minecraft! Launcher 由于当前 Java 损坏而无法继续运行。请卸载当前 Java,点击 <a href="https://bell-sw.com/pages/downloads/#downloads">此处</a> 安装合适的 Java 版本。
launcher.crash.hmcl_out_dated=Hello Minecraft! Launcher 遇到了无法处理的错误。已检测到你的启动器不是最新版本,请更新后再试。
launcher.crash.previous_run=HMCL 上次运行时可能发生了 Java 虚拟机崩溃。若此提示反复出现,你可以点击右上角帮助按钮进行求助。
launcher.update_java=请更新你的 Java。\n你可以访问 https://docs.hmcl.net/help.html 页面寻求帮助。

libraries.download=下载依赖库
Expand Down