Enable HMCL to export jstack dump file 让 HMCL 能够导出游戏运行栈文件 (#2582)

* Enable HMCL to create game thread dump while game is running

* Fix checkstyle

* Hide accessToken

* Code cleanup

* Code cleanup

* Enhance I18N and declare the charset (UTF-8) of output file

* Inline variables

* Update the modifier of org.jackhuang.hmcl.game.GameDumpCreator#writeDumpHeadTo from public to private

* Refactor

* Add license for GameDumpCreator, remove support for Java 8

* Remove unnecessary Arrays.copyOf

* Fix checkstyle

* Use system charset to read the inputstream from JVM

* opt GameDumpCreator

* retry on failed attach to vm

* update GameDumpCreator

* Opt GameDumpCreator

* Fix

* Include BCIG

* Use BCIG to get PID.

* Fix.

* Fix again.

* Code cleanup. Fix bugs.

---------

Co-authored-by: Glavo <zjx001202@gmail.com>
This commit is contained in:
Burning_TNT 2024-01-08 20:35:46 +08:00 committed by GitHub
parent 4149876e04
commit 5d3660ffb8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 326 additions and 12 deletions

View File

@ -208,7 +208,8 @@ tasks.getByName<com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar>("sha
"javafx.graphics/com.sun.prism",
"javafx.controls/com.sun.javafx.scene.control",
"javafx.controls/com.sun.javafx.scene.control.behavior",
"javafx.controls/javafx.scene.control.skin"
"javafx.controls/javafx.scene.control.skin",
"jdk.attach/sun.tools.attach"
).joinToString(" ")
)

View File

@ -747,7 +747,7 @@ public final class LauncherHelper {
if (showLogs)
Platform.runLater(() -> {
logWindow = new LogWindow();
logWindow = new LogWindow(process);
logWindow.showNormal();
logWindowLatch.countDown();
});

View File

@ -267,7 +267,7 @@ public class GameCrashWindow extends Stage {
}
private void showLogWindow() {
LogWindow logWindow = new LogWindow();
LogWindow logWindow = new LogWindow(managedProcess);
logWindow.logLine(Logging.filterForbiddenToken("Command: " + new CommandBuilder().addAll(managedProcess.getCommands())), Log4jLevel.INFO);
if (managedProcess.getClasspath() != null)

View File

@ -36,13 +36,16 @@ import javafx.scene.control.*;
import javafx.scene.input.KeyCode;
import javafx.scene.layout.*;
import javafx.stage.Stage;
import org.jackhuang.hmcl.game.GameDumpGenerator;
import org.jackhuang.hmcl.game.LauncherHelper;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.util.Holder;
import org.jackhuang.hmcl.util.CircularArrayList;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.Log4jLevel;
import org.jackhuang.hmcl.util.platform.ManagedProcess;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.SystemUtils;
import java.io.IOException;
import java.nio.file.Files;
@ -64,7 +67,6 @@ import static org.jackhuang.hmcl.util.StringUtils.parseEscapeSequence;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
/**
*
* @author huangyuhui
*/
public final class LogWindow extends Stage {
@ -90,13 +92,17 @@ public final class LogWindow extends Stage {
private boolean stopCheckLogCount = false;
public LogWindow() {
private final ManagedProcess gameProcess;
public LogWindow(ManagedProcess gameProcess) {
setScene(new Scene(impl, 800, 480));
getScene().getStylesheets().addAll(Theme.getTheme().getStylesheets(config().getLauncherFontFamily()));
setTitle(i18n("logwindow.title"));
getIcons().add(newBuiltinImage("/assets/img/icon.png"));
levelShownMap.values().forEach(property -> property.addListener((a, b, newValue) -> shakeLogs()));
this.gameProcess = gameProcess;
}
public void logLine(String filteredLine, Log4jLevel level) {
@ -219,6 +225,35 @@ public final class LogWindow extends Stage {
});
}
private void onExportDump(JFXButton button) {
thread(() -> {
if (button.getText().equals(i18n("logwindow.export_dump.dependency_ok.button"))) {
if (SystemUtils.supportJVMAttachment()) {
Platform.runLater(() -> button.setText(i18n("logwindow.export_dump.dependency_ok.doing_button")));
Path dumpFile = Paths.get("minecraft-exported-jstack-dump-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss")) + ".log").toAbsolutePath();
try {
if (gameProcess.isRunning()) {
GameDumpGenerator.writeDumpTo(gameProcess.getPID(), dumpFile);
FXUtils.showFileInExplorer(dumpFile);
}
} catch (Throwable e) {
LOG.log(Level.WARNING, "Failed to create minecraft jstack dump", e);
Platform.runLater(() -> {
Alert alert = new Alert(Alert.AlertType.ERROR, i18n("logwindow.export_dump.dependency_ok.button"));
alert.setTitle(i18n("message.error"));
alert.showAndWait();
});
}
Platform.runLater(() -> button.setText(i18n("logwindow.export_dump.dependency_ok.button")));
}
}
});
}
private void onExportGameCrashInfo() {
if (exportGameCrashInfoCallback == null) return;
exportGameCrashInfoCallback.accept(logs.stream().map(x -> x.log).collect(Collectors.joining(OperatingSystem.LINE_SEPARATOR)));
@ -303,7 +338,7 @@ public final class LogWindow extends Stage {
listView.setCellFactory(x -> new ListCell<Log>() {
{
getStyleClass().add("log-window-list-cell");
Region clippedContainer = (Region)listView.lookup(".clipped-container");
Region clippedContainer = (Region) listView.lookup(".clipped-container");
if (clippedContainer != null) {
maxWidthProperty().bind(clippedContainer.widthProperty());
prefWidthProperty().bind(clippedContainer.widthProperty());
@ -398,15 +433,25 @@ public final class LogWindow extends Stage {
autoScrollCheckBox.setSelected(true);
control.autoScroll.bind(autoScrollCheckBox.selectedProperty());
JFXButton terminateButton = new JFXButton(i18n("logwindow.terminate_game"));
terminateButton.setOnMouseClicked(e -> getSkinnable().onTerminateGame());
JFXButton exportLogsButton = new JFXButton(i18n("button.export"));
exportLogsButton.setOnMouseClicked(e -> getSkinnable().onExportLogs());
JFXButton terminateButton = new JFXButton(i18n("logwindow.terminate_game"));
terminateButton.setOnMouseClicked(e -> getSkinnable().onTerminateGame());
JFXButton exportDumpButton = new JFXButton();
if (SystemUtils.supportJVMAttachment()) {
exportDumpButton.setText(i18n("logwindow.export_dump.dependency_ok.button"));
exportDumpButton.setOnAction(e -> getSkinnable().onExportDump(exportDumpButton));
} else {
exportDumpButton.setText(i18n("logwindow.export_dump.no_dependency.button"));
exportDumpButton.setTooltip(new Tooltip(i18n("logwindow.export_dump.no_dependency.tooltip")));
exportDumpButton.setDisable(true);
}
JFXButton clearButton = new JFXButton(i18n("button.clear"));
clearButton.setOnMouseClicked(e -> getSkinnable().onClear());
hBox.getChildren().setAll(autoScrollCheckBox, exportLogsButton, terminateButton, clearButton);
hBox.getChildren().setAll(autoScrollCheckBox, exportLogsButton, terminateButton, exportDumpButton, clearButton);
vbox.getChildren().add(bottom);
}

View File

@ -723,6 +723,10 @@ logwindow.title=Log
logwindow.help=You can go to the HMCL community and find others to help
logwindow.autoscroll=Auto-scroll
logwindow.export_game_crash_logs=Export Crash Logs
logwindow.export_dump.dependency_ok.button=Export Game Stack Dump
logwindow.export_dump.dependency_ok.doing_button=Exporting Game Stack Dump (May take up to 15 seconds)
logwindow.export_dump.no_dependency.button=Export Game Stack Dump (Not compatible)
logwindow.export_dump.no_dependency.tooltip=Your Java does not contain the dependencies to create the stack dump. Please turn to HMCL KOOK or HMCL Discord for help.
main_page=Home

View File

@ -668,6 +668,11 @@ logwindow.title=Registro
logwindow.help=Puede ir a la comunidad HMCL y encontrar a otros para ayudar
logwindow.autoscroll=Desplazamiento automático
logwindow.export_game_crash_logs=Exportar registros de errores
logwindow.export_dump.dependency_ok.button=Exportar volcado de pila de juegos
logwindow.export_dump.dependency_ok.doing_button=Exportación de volcado de pila de juego (puede tardar hasta 15 segundos)
logwindow.export_dump.no_dependency.button=Exportar volcado de pila de juegos (no compatible)
logwindow.export_dump.no_dependency.tooltip=Su Java no contiene las dependencias para crear el volcado de pila. Dirígete a HMCL KOOK o HMCL Discord para obtener ayuda.
main_page=Inicio

View File

@ -597,6 +597,10 @@ logwindow.title=記錄
logwindow.help=你可以前往 HMCL 社區,尋找他人幫助
logwindow.autoscroll=自動滾動
logwindow.export_game_crash_logs=導出遊戲崩潰訊息
logwindow.export_dump.dependency_ok.button=導出遊戲運行棧
logwindow.export_dump.dependency_ok.doing_button=正在導出遊戲運行棧(可能需要 15 秒)
logwindow.export_dump.no_dependency.button=導出遊戲運行棧(不兼容)
logwindow.export_dump.no_dependency.tooltip=你的 Java 不包含用於創建遊戲運行棧的依賴。請前往 HMCL KOOK 或 HMCL Discord 尋求幫助。
main_page=首頁

View File

@ -596,6 +596,10 @@ logwindow.title=日志
logwindow.help=你可以前往 HMCL 社区,寻找他人帮助
logwindow.autoscroll=自动滚动
logwindow.export_game_crash_logs=导出游戏崩溃信息
logwindow.export_dump.dependency_ok.button=导出游戏运行栈
logwindow.export_dump.dependency_ok.doing_button=正在导出游戏运行栈(可能需要 15 秒)
logwindow.export_dump.no_dependency.button=导出游戏运行栈(不兼容)
logwindow.export_dump.no_dependency.tooltip=你的 Java 不包含用于创建游戏运行栈的依赖。请前往 HMCL KOOK 或 HMCL Discord 寻求帮助。
main_page=主页

View File

@ -1,3 +1,5 @@
import kotlin.streams.toList
plugins {
`java-library`
}
@ -13,4 +15,20 @@ dependencies {
api("org.nanohttpd:nanohttpd:2.3.1")
api("org.apache.commons:commons-compress:1.23.0")
compileOnlyApi("org.jetbrains:annotations:24.0.1")
compileOnlyApi("com.github.burningtnt:BytecodeImplGenerator:b45b6638eeaeb903aa22ea947d37c45e5716a18c")
}
tasks.getByName<JavaCompile>("compileJava") {
val bytecodeClasses = listOf(
"org/jackhuang/hmcl/util/platform/ManagedProcess"
)
doLast {
javaexec {
classpath(project.sourceSets["main"].compileClasspath)
mainClass.set("net.burningtnt.bcigenerator.BytecodeImplGenerator")
System.getProperty("bci.debug.address")?.let { address -> jvmArgs("-agentlib:jdwp=transport=dt_socket,server=n,address=$address,suspend=y") }
args(bytecodeClasses.stream().map { s -> project.layout.buildDirectory.file("classes/java/main/$s.class").get().asFile.path }.toList())
}
}
}

View File

@ -0,0 +1,174 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 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.game;
import com.sun.tools.attach.AttachNotSupportedException;
import com.sun.tools.attach.VirtualMachine;
import org.jackhuang.hmcl.util.Logging;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import java.io.*;
import java.nio.CharBuffer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.logging.Level;
import static org.jackhuang.hmcl.util.Logging.LOG;
/**
* Generate a JVM dump on a process.
* WARNING: Initializing this class may cause NoClassDefFoundError.
*/
public final class GameDumpGenerator {
private GameDumpGenerator() {
}
private static final int TOOL_VERSION = 9;
private static final int DUMP_TIME = 3;
private static final int RETRY_TIME = 3;
public static void writeDumpTo(long pid, Path path) throws IOException, InterruptedException {
try (Writer writer = Files.newBufferedWriter(path)) {
// On a local machine, the lvmid and the pid are the same.
VirtualMachine vm = attachVM(String.valueOf(pid), writer);
try {
writeDumpHeadTo(vm, writer);
for (int i = 0; i < DUMP_TIME; i++) {
if (i > 0)
Thread.sleep(3000);
writer.write("====================\n");
writeDumpBodyTo(vm, writer);
}
} finally {
vm.detach();
}
}
}
private static void writeDumpHeadTo(VirtualMachine vm, Writer writer) throws IOException {
writer.write("===== Minecraft JStack Dump =====\n");
writeDumpHeadKeyValueTo("Tool Version", String.valueOf(TOOL_VERSION), writer, false);
writeDumpHeadKeyValueTo("VM PID", vm.id(), writer, false);
StringBuilder stringBuilder = new StringBuilder();
{
execute(vm, "VM.command_line", stringBuilder);
writeDumpHeadKeyValueTo(
"VM Command Line",
Logging.filterForbiddenToken(stringBuilder.toString()),
writer,
true
);
}
{
stringBuilder.setLength(0);
execute(vm, "VM.version", stringBuilder);
writeDumpHeadKeyValueTo("VM Version", stringBuilder.toString(), writer, true);
}
writer.write("\n\n");
}
public static void writeDumpHeadKeyValueTo(String key, String value, Writer writer, boolean multiline) throws IOException {
writer.write(key);
writer.write(':');
writer.write(' ');
if (multiline) {
writer.write('{');
writer.write('\n');
int lineStart = 0;
int lineEnd = value.indexOf("\n", lineStart);
while (true) {
if (lineEnd == -1) {
if (lineStart < value.length()) {
writer.write(" ");
writer.write(value, lineStart, value.length() - lineStart);
writer.write('\n');
}
break;
} else {
writer.write(" ");
writer.write(value, lineStart, lineEnd - lineStart);
writer.write('\n');
lineStart = lineEnd + 1;
lineEnd = value.indexOf("\n", lineStart);
}
}
writer.write('}');
} else {
writer.write(value);
}
writer.write('\n');
}
private static void writeDumpBodyTo(VirtualMachine vm, Writer writer) throws IOException {
execute(vm, "Thread.print", writer);
}
private static VirtualMachine attachVM(String lvmid, Writer writer) throws IOException, InterruptedException {
for (int i = 0; i < RETRY_TIME; i++) {
try {
return VirtualMachine.attach(lvmid);
} catch (Throwable e) {
LOG.log(Level.WARNING, "An exception encountered while attaching vm " + lvmid, e);
writer.write(StringUtils.getStackTrace(e));
writer.write('\n');
Thread.sleep(3000);
}
}
String message = "Cannot attach VM " + lvmid;
writer.write(message);
throw new IOException(message);
}
private static void execute(VirtualMachine vm, String command, Appendable target) throws IOException {
try (Reader reader = new InputStreamReader(executeJVMCommand(vm, command), OperatingSystem.NATIVE_CHARSET)) {
char[] data = new char[256];
CharBuffer cb = CharBuffer.wrap(data);
int len;
while ((len = reader.read(data)) > 0) { // Directly read the data into a CharBuffer would cause useless array copy actions.
target.append(cb, 0, len);
}
} catch (Throwable throwable) {
LOG.log(Level.WARNING, "An exception encountered while executing jcmd " + vm.id(), throwable);
target.append(StringUtils.getStackTrace(throwable));
target.append('\n');
}
}
private static InputStream executeJVMCommand(VirtualMachine vm, String command) throws IOException, AttachNotSupportedException {
if (vm instanceof sun.tools.attach.HotSpotVirtualMachine) {
return ((sun.tools.attach.HotSpotVirtualMachine) vm).executeJCmd(command);
} else {
throw new AttachNotSupportedException("Unsupported VM implementation " + vm.getClass().getName());
}
}
}

View File

@ -17,10 +17,13 @@
*/
package org.jackhuang.hmcl.util.platform;
import net.burningtnt.bcigenerator.api.BytecodeImpl;
import net.burningtnt.bcigenerator.api.BytecodeImplError;
import org.jackhuang.hmcl.launch.StreamPump;
import org.jackhuang.hmcl.util.Lang;
import java.io.IOException;
import java.lang.reflect.Field;
import java.util.*;
import java.util.function.Consumer;
import java.util.function.Predicate;
@ -33,7 +36,6 @@ import java.util.function.Predicate;
* @see org.jackhuang.hmcl.launch.StreamPump
*/
public class ManagedProcess {
private final Process process;
private final List<String> commands;
private final String classpath;
@ -81,6 +83,58 @@ public class ManagedProcess {
return process;
}
/**
* The PID of the raw system process
*
* @throws UnsupportedOperationException if current Java environment is not supported.
* @return PID
*/
public long getPID() throws UnsupportedOperationException {
if (JavaVersion.CURRENT_JAVA.getParsedVersion() >= 9) {
// Method Process.pid() is provided (Java 9 or later). Invoke it to get the pid.
return getPID0(process);
} else {
// Method Process.pid() is not provided. (Java 8).
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
// On Windows, we can invoke method Process.pid() to get the pid.
// However, this method is supplied since Java 9.
// So, there is no ways to get the pid.
throw new UnsupportedOperationException("Cannot get the pid of a Process on Java 8 on Windows.");
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.OSX || OperatingSystem.CURRENT_OS == OperatingSystem.LINUX) {
// On Linux or Mac, we can get field UnixProcess.pid field to get the pid.
// All the Java version is accepted.
// See https://github.com/openjdk/jdk/blob/jdk8-b120/jdk/src/solaris/classes/java/lang/UNIXProcess.java.linux
try {
Field pidField = process.getClass().getDeclaredField("pid");
pidField.setAccessible(true);
return pidField.getInt(process);
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new UnsupportedOperationException("Cannot get the pid of a Process on Java 8 on OSX / Linux.", e);
}
} else {
// Unknown Operating System, no fallback available.
throw new UnsupportedOperationException(String.format("Cannot get the pid of a Process on Java 8 on Unknown Operating System (%s).", System.getProperty("os.name")));
}
}
}
/**
* Get the PID of a process with BytecodeImplGenerator
*/
@BytecodeImpl({
"LABEL METHOD_HEAD",
"ALOAD 0",
"INVOKEVIRTUAL Ljava/lang/Process;pid()J",
"LABEL RELEASE_PARAMETER",
"LRETURN",
"LOCALVARIABLE process [Ljava/lang/Process; METHOD_HEAD RELEASE_PARAMETER 0",
"MAXS 2 1"
})
@SuppressWarnings("unused")
private static long getPID0(Process process) {
throw new BytecodeImplError();
}
/**
* The command line.
*

View File

@ -41,8 +41,12 @@ public final class SystemUtils {
return managedProcess.getProcess().waitFor();
}
public static boolean supportJVMAttachment() {
return JavaVersion.CURRENT_JAVA.getParsedVersion() >= 9
&& Thread.currentThread().getContextClassLoader().getResource("com/sun/tools/attach/VirtualMachine.class") != null;
}
private static void onLogLine(String log) {
LOG.info(log);
}
}

View File

@ -20,6 +20,7 @@ subprojects {
}
mavenCentral()
maven(url = "https://jitpack.io")
maven(url = "https://libraries.minecraft.net")
}
tasks.withType<JavaCompile> {