From 46d77bba11c8071405c439a33adf8f351d0028b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B1=BC=E5=89=8D=E4=B8=89=E7=99=BE?= Date: Wed, 30 Sep 2026 15:51:32 +0800 Subject: [PATCH 01/44] =?UTF-8?q?refactor:=20=E5=BC=95=E5=85=A5=20ProcessI?= =?UTF-8?q?nfo=20=E6=8E=A5=E5=8F=A3=E8=A7=A3=E8=80=A6=E6=B8=B8=E6=88=8F?= =?UTF-8?q?=E8=BF=9B=E7=A8=8B=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ManagedProcess 实现 ProcessInfo,Launcher#launch、ProcessListener 及启动事件改用 ProcessInfo,GameCrashWindow、LogWindow、LauncherHelper、HMCLGameLauncher 随之适配;并从 UpdateHandler 提取 JavaProcessLauncher 至 HMCLCore。 --- .../jackhuang/hmcl/game/HMCLGameLauncher.java | 4 +- .../jackhuang/hmcl/game/LauncherHelper.java | 10 +- .../jackhuang/hmcl/ui/GameCrashWindow.java | 15 +-- .../java/org/jackhuang/hmcl/ui/LogWindow.java | 27 ++-- .../jackhuang/hmcl/upgrade/UpdateHandler.java | 50 +------- .../hmcl/event/JVMLaunchFailedEvent.java | 8 +- .../event/ProcessExitedAbnormallyEvent.java | 8 +- .../hmcl/event/ProcessStoppedEvent.java | 8 +- .../hmcl/launch/DefaultLauncher.java | 2 +- .../org/jackhuang/hmcl/launch/ExitWaiter.java | 2 +- .../org/jackhuang/hmcl/launch/Launcher.java | 7 +- .../hmcl/launch/ProcessListener.java | 4 +- .../util/platform/JavaProcessLauncher.java | 116 ++++++++++++++++++ .../hmcl/util/platform/ManagedProcess.java | 31 ++++- .../hmcl/util/platform/ProcessInfo.java | 55 +++++++++ 15 files changed, 246 insertions(+), 101 deletions(-) create mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/JavaProcessLauncher.java create mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ProcessInfo.java diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java index e13cd3d5d31..7a1404111e4 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java @@ -26,7 +26,7 @@ import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.io.JarUtils; import org.jackhuang.hmcl.util.platform.CommandBuilder; -import org.jackhuang.hmcl.util.platform.ManagedProcess; +import org.jackhuang.hmcl.util.platform.ProcessInfo; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import java.io.IOException; @@ -154,7 +154,7 @@ private static String normalizedLanguageTag(Locale locale, GameVersionNumber gam } @Override - public ManagedProcess launch() throws IOException, InterruptedException { + public ProcessInfo launch() throws IOException, InterruptedException { generateOptionsTxt(); return super.launch(); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java index aaef6607bce..7424a9630d0 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -838,7 +838,7 @@ private final class HMCLProcessListener implements ProcessListener { private final ReentrantLock lock = new ReentrantLock(); private final LaunchOptions launchOptions; - private ManagedProcess process; + private ProcessInfo process; private volatile boolean lwjgl; private LogWindow logWindow; private final boolean detectWindow; @@ -857,7 +857,7 @@ public HMCLProcessListener(AuthInfo authInfo, LaunchOptions launchOptions, Count } @Override - public void setProcess(ManagedProcess process) { + public void setProcess(ProcessInfo process) { this.process = process; String command = new CommandBuilder().addAll(process.getCommands()).toString(); @@ -1042,11 +1042,11 @@ public void onExit(int exitCode, ExitType exitType) { } - private static final Queue> PROCESSES = new ConcurrentLinkedQueue<>(); + private static final Queue> PROCESSES = new ConcurrentLinkedQueue<>(); public static int countMangedProcesses() { PROCESSES.removeIf(it -> { - ManagedProcess process = it.get(); + ProcessInfo process = it.get(); return process == null || !process.isRunning(); }); return PROCESSES.size(); @@ -1054,6 +1054,6 @@ public static int countMangedProcesses() { public static void stopManagedProcesses() { while (!PROCESSES.isEmpty()) - Optional.ofNullable(PROCESSES.poll()).map(WeakReference::get).ifPresent(ManagedProcess::stop); + Optional.ofNullable(PROCESSES.poll()).map(WeakReference::get).ifPresent(ProcessInfo::stop); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java index 380357070c9..c628a390aee 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java @@ -79,7 +79,7 @@ public class GameCrashWindow extends Stage { private final BooleanProperty loading = new SimpleBooleanProperty(); private final TextFlow feedbackTextFlow = new TextFlow(); - private final ManagedProcess managedProcess; + private final ProcessInfo managedProcess; private final ProcessListener.ExitType exitType; private final LaunchOptions launchOptions; private final View view; @@ -87,7 +87,7 @@ public class GameCrashWindow extends Stage { private final List logs; - public GameCrashWindow(ManagedProcess managedProcess, ProcessListener.ExitType exitType, HMCLGameInstance gameInstance, LaunchOptions launchOptions, List logs) { + public GameCrashWindow(ProcessInfo managedProcess, ProcessListener.ExitType exitType, HMCLGameInstance gameInstance, LaunchOptions launchOptions, List logs) { Themes.applyNativeDarkMode(this); this.managedProcess = managedProcess; @@ -277,16 +277,7 @@ private CompletableFuture exportGameCrashInfo() { return CompletableFuture.supplyAsync(() -> logs.stream().map(Log::getLog).collect(Collectors.joining("\n"))) .thenComposeAsync(logs -> { - long processStartTime = managedProcess.getProcess().info() - .startInstant() - .map(Instant::toEpochMilli).orElseGet(() -> { - try { - return ManagementFactory.getRuntimeMXBean().getStartTime(); - } catch (Throwable e) { - LOG.warning("Failed to get process start time", e); - return 0L; - } - }); + long processStartTime = managedProcess.getProcessStartTime(); return LogExporter.exportLogs(logFile, gameInstance, launchOptions, logs, new CommandBuilder().addAll(managedProcess.getCommands()).toString(), diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/LogWindow.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/LogWindow.java index 6ac9d2daf2b..79fa1182bbf 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/LogWindow.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/LogWindow.java @@ -42,7 +42,7 @@ import org.jackhuang.hmcl.util.CircularArrayList; import org.jackhuang.hmcl.util.Log4jLevel; import org.jackhuang.hmcl.util.StringUtils; -import org.jackhuang.hmcl.util.platform.ManagedProcess; +import org.jackhuang.hmcl.util.platform.ProcessInfo; import org.jackhuang.hmcl.util.platform.SystemUtils; import java.io.IOException; @@ -79,13 +79,13 @@ public final class LogWindow extends Stage { } private final LogWindowImpl impl; - private final ManagedProcess gameProcess; + private final ProcessInfo gameProcess; - public LogWindow(ManagedProcess gameProcess) { + public LogWindow(ProcessInfo gameProcess) { this(gameProcess, new CircularArrayList<>()); } - public LogWindow(ManagedProcess gameProcess, CircularArrayList logs) { + public LogWindow(ProcessInfo gameProcess, CircularArrayList logs) { Themes.applyNativeDarkMode(this); this.logs = logs; @@ -235,7 +235,7 @@ private void onExportDump(SpinnerPane pane) { try { if (gameProcess.isRunning()) { - GameDumpGenerator.writeDumpTo(gameProcess.getProcess().pid(), dumpFile); + GameDumpGenerator.writeDumpTo(gameProcess.getPid(), dumpFile); FXUtils.showFileInExplorer(dumpFile); } } catch (Throwable e) { @@ -251,7 +251,7 @@ private void onExportDump(SpinnerPane pane) { }); } - private ManagedProcess getGameProcess() { + private ProcessInfo getGameProcess() { return gameProcess; } @@ -433,12 +433,15 @@ protected void updateItem(Log item, boolean empty) { clearButton.setOnAction(e -> getSkinnable().onClear()); hBox.getChildren().setAll(autoScrollCheckBox, wrapTextCheckBox, exportLogsButton, terminateButton, exportDumpPane, clearButton); - control.getGameProcess().getProcess() - .onExit() - .thenRunAsync(() -> { - terminateButton.setDisable(true); - exportDumpButton.setDisable(true); - }, Schedulers.javafx()); + ProcessHandle gameHandle = control.getGameProcess().getHandle(); + Runnable disableButtons = () -> { + terminateButton.setDisable(true); + exportDumpButton.setDisable(true); + }; + if (gameHandle != null) + gameHandle.onExit().thenRunAsync(disableButtons, Schedulers.javafx()); + else if (!control.getGameProcess().isRunning()) + disableButtons.run(); vbox.getChildren().add(bottom); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateHandler.java b/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateHandler.java index 9d2e23ac89c..141cc60a89c 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateHandler.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateHandler.java @@ -35,6 +35,7 @@ import org.jackhuang.hmcl.util.SwingUtils; import org.jackhuang.hmcl.util.TaskCancellationAction; import org.jackhuang.hmcl.util.io.JarUtils; +import org.jackhuang.hmcl.util.platform.JavaProcessLauncher; import org.jackhuang.hmcl.util.platform.OperatingSystem; import java.io.IOException; @@ -198,54 +199,7 @@ private static void requestUpdate(Path updateTo, Path self) throws IOException { } public static void startJava(Path jar, String... appArgs) throws IOException { - List commandline = new ArrayList<>(); - commandline.add(JavaRuntime.getDefault().getBinary().toString()); - - try { - for (String inputArgument : ManagementFactory.getRuntimeMXBean().getInputArguments()) { - if (inputArgument.startsWith("-D") || inputArgument.startsWith("-X")) { - commandline.add(inputArgument); - } - } - } catch (Throwable ignored) { - // ManagementFactory not available - for (Map.Entry entry : System.getProperties().entrySet()) { - if (entry.getKey() instanceof String key && key.startsWith("hmcl.")) { - commandline.add("-D" + key + "=" + entry.getValue()); - } - } - } - - commandline.add("-jar"); - commandline.add(jar.toAbsolutePath().toString()); - commandline.addAll(Arrays.asList(appArgs)); - LOG.info("Starting process: " + maskCommandline(commandline)); - new ProcessBuilder(commandline) - .directory(Paths.get("").toAbsolutePath().toFile()) - .inheritIO() - .start(); - } - - private static String maskCommandline(List commandline) { - return commandline.stream().map(str -> { - if (str.startsWith("-D")) { - int eqIdx = str.indexOf('='); - if (eqIdx != -1) { - String key = str.substring(2, eqIdx); - String value = str.substring(eqIdx + 1); - if (key.contains("http.proxy") || - key.startsWith("https.proxy") || - key.startsWith("socksProxy") || - key.equals("hmcl.microsoft.auth.id") || - key.equals("hmcl.curseforge.apikey") - ) { - return "-D" + key + "=" + (value.isEmpty() ? "" : value.charAt(0) + "*".repeat(value.length() - 1)); - } - } - } - - return str; - }).collect(Collectors.joining(" ")); + JavaProcessLauncher.startJava(jar, appArgs); } private static Optional tryRename(Path path, String newVersion) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/event/JVMLaunchFailedEvent.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/event/JVMLaunchFailedEvent.java index 4907b802865..c05d65eb5b1 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/event/JVMLaunchFailedEvent.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/event/JVMLaunchFailedEvent.java @@ -18,7 +18,7 @@ package org.jackhuang.hmcl.event; import org.jackhuang.hmcl.util.ToStringBuilder; -import org.jackhuang.hmcl.util.platform.ManagedProcess; +import org.jackhuang.hmcl.util.platform.ProcessInfo; /** * This event gets fired when we launch the JVM and it got crashed. @@ -29,7 +29,7 @@ */ public class JVMLaunchFailedEvent extends Event { - private final ManagedProcess process; + private final ProcessInfo process; /** * Constructor. @@ -37,12 +37,12 @@ public class JVMLaunchFailedEvent extends Event { * @param source {@link org.jackhuang.hmcl.launch.ExitWaiter} * @param process the crashed process. */ - public JVMLaunchFailedEvent(Object source, ManagedProcess process) { + public JVMLaunchFailedEvent(Object source, ProcessInfo process) { super(source); this.process = process; } - public ManagedProcess getProcess() { + public ProcessInfo getProcess() { return process; } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/event/ProcessExitedAbnormallyEvent.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/event/ProcessExitedAbnormallyEvent.java index c0931da977d..5b7ef89fafe 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/event/ProcessExitedAbnormallyEvent.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/event/ProcessExitedAbnormallyEvent.java @@ -18,7 +18,7 @@ package org.jackhuang.hmcl.event; import org.jackhuang.hmcl.util.ToStringBuilder; -import org.jackhuang.hmcl.util.platform.ManagedProcess; +import org.jackhuang.hmcl.util.platform.ProcessInfo; /** * This event gets fired when a JavaProcess exited abnormally and the exit code is not zero. @@ -29,7 +29,7 @@ */ public final class ProcessExitedAbnormallyEvent extends Event { - private final ManagedProcess process; + private final ProcessInfo process; /** * Constructor. @@ -37,12 +37,12 @@ public final class ProcessExitedAbnormallyEvent extends Event { * @param source {@link org.jackhuang.hmcl.launch.ExitWaiter} * @param process The process that exited abnormally. */ - public ProcessExitedAbnormallyEvent(Object source, ManagedProcess process) { + public ProcessExitedAbnormallyEvent(Object source, ProcessInfo process) { super(source); this.process = process; } - public ManagedProcess getProcess() { + public ProcessInfo getProcess() { return process; } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/event/ProcessStoppedEvent.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/event/ProcessStoppedEvent.java index 328dfe45e35..bcb899b5cd2 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/event/ProcessStoppedEvent.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/event/ProcessStoppedEvent.java @@ -17,7 +17,7 @@ */ package org.jackhuang.hmcl.event; -import org.jackhuang.hmcl.util.platform.ManagedProcess; +import org.jackhuang.hmcl.util.platform.ProcessInfo; /** * This event gets fired when minecraft process exited successfully and the exit code is 0. @@ -28,7 +28,7 @@ */ public class ProcessStoppedEvent extends Event { - private final ManagedProcess process; + private final ProcessInfo process; /** * Constructor. @@ -36,12 +36,12 @@ public class ProcessStoppedEvent extends Event { * @param source {@link org.jackhuang.hmcl.launch.ExitWaiter} * @param process minecraft process */ - public ProcessStoppedEvent(Object source, ManagedProcess process) { + public ProcessStoppedEvent(Object source, ProcessInfo process) { super(source); this.process = process; } - public ManagedProcess getProcess() { + public ProcessInfo getProcess() { return process; } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java index 86d358044a2..6e75db0833c 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java @@ -662,7 +662,7 @@ private Path getNativeFolder() { } @Override - public ManagedProcess launch() throws IOException, InterruptedException { + public ProcessInfo launch() throws IOException, InterruptedException { Path nativeFolder = getNativeFolder(); final Command command = generateCommandLine(nativeFolder); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/ExitWaiter.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/ExitWaiter.java index 5ebcea32071..e68e86deb5d 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/ExitWaiter.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/ExitWaiter.java @@ -33,7 +33,7 @@ /** * @author huangyuhui */ -final class ExitWaiter implements Runnable { +public final class ExitWaiter implements Runnable { private final ManagedProcess process; private final Collection joins; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/Launcher.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/Launcher.java index 51a4f53ea6a..1f0af7fce74 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/Launcher.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/Launcher.java @@ -21,7 +21,7 @@ import org.jackhuang.hmcl.game.GameInstance; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.LaunchOptions; -import org.jackhuang.hmcl.util.platform.ManagedProcess; +import org.jackhuang.hmcl.util.platform.ProcessInfo; import java.io.IOException; import java.nio.file.Path; @@ -84,9 +84,10 @@ public GameInstance getInstance() { /// Starts the game process. /// - /// @return the managed process + /// @return the information of the launched game process; a [org.jackhuang.hmcl.util.platform.ManagedProcess] when the process is + /// spawned directly, or a view of the monitor-supervised process otherwise /// @throws IOException if the process cannot be created or launch preparation fails /// @throws InterruptedException if interrupted while preparing or starting the process - public abstract ManagedProcess launch() throws IOException, InterruptedException; + public abstract ProcessInfo launch() throws IOException, InterruptedException; } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/ProcessListener.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/ProcessListener.java index 523b9cf93c8..7c5fcb66c7b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/ProcessListener.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/ProcessListener.java @@ -17,7 +17,7 @@ */ package org.jackhuang.hmcl.launch; -import org.jackhuang.hmcl.util.platform.ManagedProcess; +import org.jackhuang.hmcl.util.platform.ProcessInfo; /** * @@ -29,7 +29,7 @@ public interface ProcessListener { * When a game launched, this method will be called to get the new process. * You should not override this method when your ProcessListener is shared with all processes. */ - default void setProcess(ManagedProcess process) { + default void setProcess(ProcessInfo process) { } /** diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/JavaProcessLauncher.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/JavaProcessLauncher.java new file mode 100644 index 00000000000..e083e4f7893 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/JavaProcessLauncher.java @@ -0,0 +1,116 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui 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 . + */ +package org.jackhuang.hmcl.util.platform; + +import org.jackhuang.hmcl.java.JavaRuntime; +import org.jetbrains.annotations.NotNullByDefault; + +import java.io.IOException; +import java.lang.management.ManagementFactory; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// Starts new JVM processes for a jar file, inheriting the current JVM's arguments. +@NotNullByDefault +public final class JavaProcessLauncher { + + private JavaProcessLauncher() { + } + + /// Builds the command line that starts a new JVM running the given jar with the given arguments. + /// + ///

The current JVM's {@code -D} and {@code -X} input arguments are inherited; when the + /// Management API is not available, only the {@code hmcl.}-prefixed system properties are + /// carried over as {@code -D} arguments. + /// + /// @param jar the jar file to run + /// @param appArgs the application arguments passed to the new process + /// @return the command line, one argument per element + public static List buildJavaCommandLine(Path jar, String... appArgs) { + List commandline = new ArrayList<>(); + commandline.add(JavaRuntime.getDefault().getBinary().toString()); + + try { + for (String inputArgument : ManagementFactory.getRuntimeMXBean().getInputArguments()) { + if (inputArgument.startsWith("-D") || inputArgument.startsWith("-X")) { + commandline.add(inputArgument); + } + } + } catch (Throwable ignored) { + // ManagementFactory not available + for (Map.Entry entry : System.getProperties().entrySet()) { + if (entry.getKey() instanceof String key && key.startsWith("hmcl.")) { + commandline.add("-D" + key + "=" + entry.getValue()); + } + } + } + + commandline.add("-jar"); + commandline.add(jar.toAbsolutePath().toString()); + commandline.addAll(Arrays.asList(appArgs)); + return commandline; + } + + /// Masks the values of sensitive {@code -D} arguments in the given command line, so that it can + /// be logged safely. + /// + /// @param commandline the command line, one argument per element + /// @return the human-readable, masked single-line representation + public static String maskCommandLine(List commandline) { + return commandline.stream().map(str -> { + if (str.startsWith("-D")) { + int eqIdx = str.indexOf('='); + if (eqIdx != -1) { + String key = str.substring(2, eqIdx); + String value = str.substring(eqIdx + 1); + if (key.contains("http.proxy") || key.startsWith("https.proxy") || key.startsWith("socksProxy") + || key.equals("hmcl.microsoft.auth.id") || key.equals("hmcl.curseforge.apikey")) { + return "-D" + key + "=" + (value.isEmpty() ? "" : + value.charAt(0) + "*".repeat(value.length() - 1)); + } + } + } + + return str; + }).collect(Collectors.joining(" ")); + } + + /// Starts a new JVM process running the given jar with the given arguments. + /// + ///

Fire-and-forget: the process inherits this process's I/O and is never waited for. The + /// masked command line is written to the log before the process is started. + /// + /// @param jar the jar file to run + /// @param appArgs the application arguments passed to the new process + /// @throws IOException if the process cannot be created + public static void startJava(Path jar, String... appArgs) throws IOException { + List commandline = buildJavaCommandLine(jar, appArgs); + LOG.info("Starting process: " + maskCommandLine(commandline)); + new ProcessBuilder(commandline) + .directory(Paths.get("").toAbsolutePath().toFile()) + .inheritIO() + .start(); + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ManagedProcess.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ManagedProcess.java index 858d14e9f32..646fb8071d6 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ManagedProcess.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ManagedProcess.java @@ -19,23 +19,26 @@ import org.jackhuang.hmcl.launch.StreamPump; import org.jackhuang.hmcl.util.Lang; +import org.jetbrains.annotations.Nullable; import java.io.IOException; +import java.time.Instant; import java.util.*; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Consumer; import java.util.function.Predicate; -/// The managed process. +/// The game process spawned and owned by this JVM process. /// /// @author huangyuhui /// /// @see org.jackhuang.hmcl.launch.StreamPump -public final class ManagedProcess { +public final class ManagedProcess implements ProcessInfo { private final ReentrantLock lock = new ReentrantLock(); private final Process process; private final List commands; private final String classpath; + private final long processStartTime; private final Map properties = new HashMap<>(); private final List lines = new ArrayList<>(); private final List relatedThreads = new ArrayList<>(); @@ -44,6 +47,7 @@ public ManagedProcess(ProcessBuilder processBuilder) throws IOException { this.process = processBuilder.start(); this.commands = processBuilder.command(); this.classpath = null; + this.processStartTime = process.info().startInstant().map(Instant::toEpochMilli).orElseGet(System::currentTimeMillis); } /** @@ -56,6 +60,7 @@ public ManagedProcess(Process process, List commands) { this.process = process; this.commands = List.copyOf(commands); this.classpath = null; + this.processStartTime = process.info().startInstant().map(Instant::toEpochMilli).orElseGet(System::currentTimeMillis); } /** @@ -69,6 +74,7 @@ public ManagedProcess(Process process, List commands, String classpath) this.process = process; this.commands = List.copyOf(commands); this.classpath = classpath; + this.processStartTime = process.info().startInstant().map(Instant::toEpochMilli).orElseGet(System::currentTimeMillis); } /** @@ -80,11 +86,27 @@ public Process getProcess() { return process; } + @Override + public long getPid() { + return process.pid(); + } + + @Override + public ProcessHandle getHandle() { + return process.toHandle(); + } + + @Override + public long getProcessStartTime() { + return processStartTime; + } + /** * The command line. * * @return the list of each part of command line separated by spaces. */ + @Override public List getCommands() { return commands; } @@ -94,7 +116,8 @@ public List getCommands() { * * @return classpath */ - public String getClasspath() { + @Override + public @Nullable String getClasspath() { return classpath; } @@ -163,6 +186,7 @@ public void pumpErrorStream(Consumer onLogLine) { /** * True if the managed process is running. */ + @Override public boolean isRunning() { try { process.exitValue(); @@ -182,6 +206,7 @@ public int getExitCode() { /** * Destroys the raw process and other related threads that are monitoring this raw process. */ + @Override public void stop() { process.destroy(); destroyRelatedThreads(); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ProcessInfo.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ProcessInfo.java new file mode 100644 index 00000000000..0a7a4dff76b --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ProcessInfo.java @@ -0,0 +1,55 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui 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 . + */ +package org.jackhuang.hmcl.util.platform; + +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +/// The information about a game process that process listeners and UIs can rely on, regardless of +/// whether the process is owned by this JVM ([ManagedProcess]) or supervised by another process, +/// e.g. the HMCL monitor. +@NotNullByDefault +public interface ProcessInfo { + + /// The process id of the game process. + long getPid(); + + /// The handle of the game process, or `null` if it cannot be resolved anymore, e.g. a dead + /// process whose id has already been reaped by the operating system. + @Nullable ProcessHandle getHandle(); + + /// The command line of the game process. + List getCommands(); + + /// The classpath of the game process, or `null` when unknown. + @Nullable String getClasspath(); + + /// The epoch millisecond at which the game process started. + long getProcessStartTime(); + + /// Returns whether the game process is still running. + boolean isRunning(); + + /// Destroys the game process. + /// + ///

Implementations that monitor the process with extra threads must also interrupt them, so + /// that a cancelled launch is not mistaken for a crashed game. + void stop(); +} From 6833a668920cebd77970644f83e6eed4b1d746b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B1=BC=E5=89=8D=E4=B8=89=E7=99=BE?= Date: Wed, 30 Sep 2026 16:59:28 +0800 Subject: [PATCH 02/44] =?UTF-8?q?chore:=20=E6=B8=85=E7=90=86=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java | 2 -- .../main/java/org/jackhuang/hmcl/upgrade/UpdateHandler.java | 3 --- 2 files changed, 5 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java index c628a390aee..d26fae33de1 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java @@ -51,12 +51,10 @@ import org.jackhuang.hmcl.util.platform.*; import java.io.IOException; -import java.lang.management.ManagementFactory; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.attribute.FileTime; -import java.time.Instant; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.*; diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateHandler.java b/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateHandler.java index 141cc60a89c..e6d80b9a0e4 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateHandler.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateHandler.java @@ -23,7 +23,6 @@ import org.jackhuang.hmcl.EntryPoint; import org.jackhuang.hmcl.Main; import org.jackhuang.hmcl.Metadata; -import org.jackhuang.hmcl.java.JavaRuntime; import org.jackhuang.hmcl.setting.SettingsManager; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.task.TaskExecutor; @@ -39,7 +38,6 @@ import org.jackhuang.hmcl.util.platform.OperatingSystem; import java.io.IOException; -import java.lang.management.ManagementFactory; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -48,7 +46,6 @@ import java.util.concurrent.CountDownLatch; import java.util.regex.Matcher; import java.util.regex.Pattern; -import java.util.stream.Collectors; import static org.jackhuang.hmcl.ui.FXUtils.checkFxUserThread; import static org.jackhuang.hmcl.util.Lang.thread; From 4279e76a68d08c09956c7d848b484c4fc08a8ab2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B1=BC=E5=89=8D=E4=B8=89=E7=99=BE?= Date: Wed, 30 Sep 2026 19:18:56 +0800 Subject: [PATCH 03/44] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20HMCLMonitor?= =?UTF-8?q?=20=E5=AD=90=E9=A1=B9=E7=9B=AE=EF=BC=8C=E8=AE=BE=E7=BD=AE=20HMC?= =?UTF-8?q?L=5FUSE=5FHMCLMONITOR=20=E7=8E=AF=E5=A2=83=E5=8F=98=E9=87=8F?= =?UTF-8?q?=E5=90=AF=E7=94=A8=E3=80=82=E4=BD=BF=E7=94=A8=E8=BD=BB=E9=87=8F?= =?UTF-8?q?=E8=BF=9B=E7=A8=8B=E7=9B=91=E7=9D=A3=E6=B8=B8=E6=88=8F=E8=BF=9B?= =?UTF-8?q?=E7=A8=8B=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + HMCL/build.gradle.kts | 2 + .../java/org/jackhuang/hmcl/EntryPoint.java | 7 + .../jackhuang/hmcl/game/HMCLGameLauncher.java | 5 + .../jackhuang/hmcl/game/LauncherHelper.java | 36 ++- .../hmcl/launch/DefaultLauncher.java | 68 +++++ HMCLMonitor/build.gradle.kts | 14 + .../jackhuang/hmcl/monitor/MonitorClient.java | 246 +++++++++++++++++ .../hmcl/monitor/MonitorGameProcess.java | 103 ++++++++ .../hmcl/monitor/MonitorProtocol.java | 70 +++++ .../hmcl/monitor/MonitorSupervisor.java | 250 ++++++++++++++++++ .../jackhuang/hmcl/monitor/ProcessSpec.java | 71 +++++ .../jackhuang/hmcl/monitor/ResultSpec.java | 56 ++++ settings.gradle.kts | 3 +- 14 files changed, 931 insertions(+), 3 deletions(-) create mode 100644 HMCLMonitor/build.gradle.kts create mode 100644 HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java create mode 100644 HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java create mode 100644 HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java create mode 100644 HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java create mode 100644 HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java create mode 100644 HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ResultSpec.java diff --git a/.gitignore b/.gitignore index 6794f1e21d9..5db70adf139 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ language-subtag-registry /HMCL/build/ /HMCLCore/build/ /HMCLBoot/build/ +/HMCLMonitor/build/ /minecraft/libraries/HMCLTransformerDiscoveryService/build/ /minecraft/libraries/HMCLMultiMCBootstrap/build/ /buildSrc/build/ @@ -42,6 +43,7 @@ language-subtag-registry /out/ /HMCL/out/ /HMCLCore/out/ +/HMCLMonitor/out/ /minecraft/libraries/HMCLTransformerDiscoveryService/out/ /minecraft/libraries/HMCLMultiMCBootstrap/out/ @@ -50,6 +52,7 @@ language-subtag-registry /HMCL/bin/ /HMCLBoot/bin/ /HMCLCore/bin/ +/HMCLMonitor/bin/ /minecraft/libraries/HMCLTransformerDiscoveryService/bin/ /minecraft/libraries/HMCLMultiMCBootstrap/bin/ .classpath diff --git a/HMCL/build.gradle.kts b/HMCL/build.gradle.kts index 94c97a8b792..8ff4e8b969e 100644 --- a/HMCL/build.gradle.kts +++ b/HMCL/build.gradle.kts @@ -58,6 +58,7 @@ val embedResources = configurations.register("embedResources") dependencies { implementation(project(":HMCLCore")) implementation(project(":HMCLBoot")) + implementation(project(":HMCLMonitor")) implementation("libs:JFoenix") implementation(libs.jwebp) implementation(libs.fxsvgimage) @@ -210,6 +211,7 @@ tasks.shadowJar { exclude(dependency("net.java.dev.jna:jna:.*")) exclude(dependency("libs:JFoenix:.*")) exclude(project(":HMCLBoot")) + exclude(project(":HMCLMonitor")) } manifest.attributes( diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/EntryPoint.java b/HMCL/src/main/java/org/jackhuang/hmcl/EntryPoint.java index 3ba178e2599..2978f8f8f8f 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/EntryPoint.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/EntryPoint.java @@ -20,6 +20,7 @@ import org.jackhuang.hmcl.util.FileSaver; import org.jackhuang.hmcl.util.SelfDependencyPatcher; import org.jackhuang.hmcl.util.SwingUtils; +import org.jackhuang.hmcl.monitor.MonitorSupervisor; import org.jackhuang.hmcl.java.JavaRuntime; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.io.JarUtils; @@ -50,6 +51,12 @@ public static void main(String[] args) { createHMCLDirectories(); LOG.start(Metadata.HMCL_LOCAL_HOME.resolve("logs")); + // The monitor role must be dispatched before JavaFX is initialized, so that the monitor + // process stays lightweight and works without a JavaFX installation. + if (MonitorSupervisor.processArguments(args)) { + exit(0); + } + checkWine(); if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java index 7a1404111e4..125c58fac6d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java @@ -17,6 +17,7 @@ */ package org.jackhuang.hmcl.game; +import org.jackhuang.hmcl.monitor.MonitorClient; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.auth.AuthInfo; import org.jackhuang.hmcl.launch.DefaultLauncher; @@ -51,6 +52,10 @@ public final class HMCLGameLauncher extends DefaultLauncher { /// @param listener process listener, or `null` to inherit IO public HMCLGameLauncher(GameInstance instance, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener) { this(instance, manifest, authInfo, options, listener, true); + + if (MonitorClient.isEnabled()) { + setLaunchMonitor(MonitorClient::launchNewMonitor); + } } /// Creates a launcher for the given instance and launch plan. diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java index 7424a9630d0..a192fb48f63 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -23,6 +23,7 @@ import org.jackhuang.hmcl.auth.*; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDownloadException; import org.jackhuang.hmcl.auth.offline.OfflineAccount; +import org.jackhuang.hmcl.monitor.MonitorClient; import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.DownloadProvider; import org.jackhuang.hmcl.download.game.*; @@ -283,7 +284,7 @@ private void launch0() { LOG.info("Here's the structure of game mod directory:\n" + FileUtils.printFileStructure(gameInstance.getModsDirectory(), 10)); - return new HMCLGameLauncher( + HMCLGameLauncher launcher = new HMCLGameLauncher( gameInstance, launchManifest.get(), authInfo, @@ -292,6 +293,20 @@ private void launch0() { ? null // Unnecessary to start listening to game process output when close launcher immediately after game launched. : new HMCLProcessListener(authInfo, launchOptions, launchingLatch, gameInstance.getVersion().compareTo(GameVersionNumber.unknown()) != 0) ); + + // The HMCL monitor supervises the game process and relaunches HMCL after it + // exits, so the main launcher process may exit once the game window is up. + // With the log window shown, the main process stays alive and handles the + // game exit itself, so nothing should be relaunched. + launcher.setRelaunchPolicy(showLogs + ? DefaultLauncher.MonitorLaunchContext.RelaunchPolicy.NEVER + : switch (launcherVisibility) { + case HIDE_AND_REOPEN -> DefaultLauncher.MonitorLaunchContext.RelaunchPolicy.ALWAYS; + case HIDE -> DefaultLauncher.MonitorLaunchContext.RelaunchPolicy.ON_CRASH; + default -> DefaultLauncher.MonitorLaunchContext.RelaunchPolicy.NEVER; + }); + + return launcher; }).thenComposeAsync(launcher -> { // launcher is prev task's result if (scriptFile == null) { return Task.supplyAsync(launcher::launch); @@ -925,6 +940,23 @@ public void run() { } } + /// Called when the game window has been detected and the launch has succeeded. + /// + ///

When the game process is managed by the HMCL monitor, the main launcher process may exit + /// right away for the [LauncherVisibility#HIDE] and [LauncherVisibility#HIDE_AND_REOPEN] + /// visibilities: the monitor supervises the game process, analyzes its exit, and relaunches + /// HMCL if configured to, so lingering here would only waste resources. + private void finishLaunchAfterWindowDetected() { + if (MonitorClient.isEnabled() && !showLogs + && (launcherVisibility == LauncherVisibility.HIDE + || launcherVisibility == LauncherVisibility.HIDE_AND_REOPEN)) { + launchingLatch.countDown(); + runLater(Launcher::stopApplication); + } else { + finishLaunch(); + } + } + private void finishLaunch() { switch (launcherVisibility) { case HIDE_AND_REOPEN: @@ -995,7 +1027,7 @@ public void onLog(String log, boolean isErrorStream) { try { if (!lwjgl) { lwjgl = true; - finishLaunch(); + finishLaunchAfterWindowDetected(); } } finally { lock.unlock(); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java index 6e75db0833c..4be073f055b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java @@ -53,6 +53,51 @@ /// @author huangyuhui public class DefaultLauncher extends Launcher { + /// Launches the game process described by the context via the HMCL monitor process. + @FunctionalInterface + public interface LaunchMonitorFunction { + /// @param context the launch context prepared by [DefaultLauncher] + /// @return a view of the game process launched and supervised by the monitor + ProcessInfo apply(MonitorLaunchContext context) throws IOException; + } + + /// The context needed to launch a game process via the HMCL monitor process. + /// + /// @param builder the process builder prepared by [DefaultLauncher]; its command line, + /// working directory and environment are already complete + /// @param listener the process listener that receives game output and exit events, + /// or `null` if game output is not needed + /// @param encoding the charset of the game process output + /// @param options the launch options, providing instance identity for the result report + /// @param postExitCommand the post-exit command to run after the game exits, already tokenized + /// and with placeholders expanded, or `null` if not configured + /// @param relaunchPolicy what the monitor should do after the game process exits + public record MonitorLaunchContext( + ProcessBuilder builder, + @Nullable ProcessListener listener, + Charset encoding, + LaunchOptions options, + @Nullable List postExitCommand, + RelaunchPolicy relaunchPolicy) { + + /// What the HMCL monitor process should do after the game process exits. + public enum RelaunchPolicy { + /// Never relaunch the launcher after the game exits. + NEVER, + /// Relaunch the launcher when the game exits abnormally, so that it presents the + /// crash window. + ON_CRASH, + /// Relaunch the launcher unconditionally after the game exits, e.g. for the + /// "hide and reopen" launcher visibility. + ALWAYS + } + } + + /// If non-null, game processes are created via the HMCL monitor process instead of directly. + private @Nullable LaunchMonitorFunction launchMonitor = null; + /// The relaunch policy applied by the HMCL monitor process after the game process exits. + private MonitorLaunchContext.RelaunchPolicy relaunchPolicy = MonitorLaunchContext.RelaunchPolicy.NEVER; + public DefaultLauncher(GameInstance instance, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) { super(instance, manifest, authInfo, options, listener, daemon); } @@ -661,6 +706,18 @@ private Path getNativeFolder() { return Path.of(options.getNativesDir()); } + /// Sets the monitor launcher; when non-null, [launching][#launch] delegates process creation to + /// the HMCL monitor process. + public void setLaunchMonitor(@Nullable LaunchMonitorFunction launchMonitor) { + this.launchMonitor = launchMonitor; + } + + /// Sets what the HMCL monitor process should do after the game process exits. Ignored unless + /// [a monitor launcher is set][#setLaunchMonitor]. + public void setRelaunchPolicy(MonitorLaunchContext.RelaunchPolicy relaunchPolicy) { + this.relaunchPolicy = relaunchPolicy; + } + @Override public ProcessInfo launch() throws IOException, InterruptedException { Path nativeFolder = getNativeFolder(); @@ -704,6 +761,17 @@ public ProcessInfo launch() throws IOException, InterruptedException { if (appdata != null) builder.environment().put("APPDATA", appdata.toString()); builder.environment().putAll(getEnvVars(nativeFolder)); + + if (launchMonitor != null) { + // The post-exit command must be executed by the monitor, since the main launcher + // process may exit before the game process does. + List postExitCommand = null; + if (StringUtils.isNotBlank(options.getPostExitCommand())) + postExitCommand = StringUtils.tokenize(options.getPostExitCommand(), getEnvVars(nativeFolder)); + + return launchMonitor.apply(new MonitorLaunchContext(builder, listener, command.encoding, options, postExitCommand, relaunchPolicy)); + } + process = builder.start(); } catch (IOException e) { throw new ProcessCreationException(e); diff --git a/HMCLMonitor/build.gradle.kts b/HMCLMonitor/build.gradle.kts new file mode 100644 index 00000000000..8e890532001 --- /dev/null +++ b/HMCLMonitor/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + `java-library` +} + +tasks.withType { + sourceCompatibility = "17" + targetCompatibility = "17" +} + +dependencies { + api(project(":HMCLCore")) + + compileOnlyApi(libs.jetbrains.annotations) +} diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java new file mode 100644 index 00000000000..3e735fdf5cf --- /dev/null +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java @@ -0,0 +1,246 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui 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 . + */ +package org.jackhuang.hmcl.monitor; + +import org.jackhuang.hmcl.event.EventBus; +import org.jackhuang.hmcl.event.JVMLaunchFailedEvent; +import org.jackhuang.hmcl.event.ProcessExitedAbnormallyEvent; +import org.jackhuang.hmcl.event.ProcessStoppedEvent; +import org.jackhuang.hmcl.game.LaunchOptions; +import org.jackhuang.hmcl.java.JavaRuntime; +import org.jackhuang.hmcl.launch.DefaultLauncher.MonitorLaunchContext; +import org.jackhuang.hmcl.launch.ProcessListener; +import org.jackhuang.hmcl.util.Lang; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.io.JarUtils; +import org.jackhuang.hmcl.util.platform.JavaProcessLauncher; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.UnknownNullability; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// The main launcher process role of the monitor feature: launching the game process through a +/// freshly spawned monitor process and consuming its protocol stream. +@NotNullByDefault +public final class MonitorClient { + + /// Whether the monitor feature is enabled through the {@code HMCL_USE_HMCLMONITOR} environment variable. + private static final boolean DAEMON_ENABLED = System.getenv("HMCL_USE_HMCLMONITOR") != null; + + private MonitorClient() { + } + + /// Returns whether the monitor feature is enabled. + public static boolean isEnabled() { + return DAEMON_ENABLED; + } + + /// Launches the game process via a new HMCL monitor process and returns a view of it. + /// + ///

This method blocks until the monitor has created the game process and reported its id + /// back. A reader thread is spawned afterward to dispatch the monitor's protocol messages: + /// game output lines are forwarded to the [listener][MonitorLaunchContext#listener()], and the + /// exit event triggers [ProcessListener#onExit]. The reader thread keeps draining the stream + /// even when the listener is absent, since an undrained pipe would eventually block the monitor. + /// + /// @param context the launch context prepared by [org.jackhuang.hmcl.launch.DefaultLauncher] + /// @return a view of the game process managed by the monitor + /// @throws IOException if the monitor process could not be started, failed to create the game + /// process, or did not complete the handshake in time + public static MonitorGameProcess launchNewMonitor(MonitorLaunchContext context) throws IOException { + Path thisJar = JarUtils.thisJarPath(); + if (thisJar == null) + throw new IOException("Failed to find current HMCL location"); + + LaunchOptions options = context.options(); + + Path specFile = Files.createTempFile("hmcl-monitor-spec-", ".json"); + JsonUtils.writeToJsonFile(specFile, buildSpec(context, options)); + + Process daemon = startDaemonProcess(thisJar, specFile); + BufferedReader reader = new BufferedReader(new InputStreamReader(daemon.getErrorStream(), MonitorProtocol.CHARSET)); + + String[] handshake = readHandshake(daemon, reader); + long pid; + long processStartTime; + try { + pid = Long.parseLong(handshake[1]); + processStartTime = Long.parseLong(handshake[2]); + } catch (NumberFormatException e) { + throw new IOException("Malformed handshake message from HMCL monitor: " + String.join("\t", handshake), e); + } + + ProcessHandle gameHandle = ProcessHandle.of(pid).orElse(null); + MonitorGameProcess process = new MonitorGameProcess(gameHandle, context.builder().command(), processStartTime); + + ProcessListener listener = context.listener(); + if (listener != null) + listener.setProcess(process); + + Thread readerThread = Lang.thread(() -> { + try { + String line; + while ((line = reader.readLine()) != null) { + dispatchProtocolMessage(line, listener, process); + } + } catch (IOException e) { + LOG.error("Failed to read from the HMCL monitor", e); + } + }, "hmcl-monitor-reader", true); + process.attachReaderThread(readerThread); + + return process; + } + + /// Builds the [ProcessSpec] to exchange with the monitor as JSON. + private static ProcessSpec buildSpec(MonitorLaunchContext context, LaunchOptions options) { + ProcessBuilder builder = context.builder(); + ProcessSpec spec = new ProcessSpec(); + spec.command = new ArrayList<>(builder.command()); + if (builder.directory() != null) + spec.directory = builder.directory().getAbsolutePath(); + spec.environment = new LinkedHashMap<>(builder.environment()); + spec.encoding = context.encoding().name(); + // The direct launch path lets a listener-less game inherit the launcher's standard input; + // keep that behavior for the CLOSE-like launcher visibility. + spec.inheritStdin = context.listener() == null; + spec.postExitCommand = context.postExitCommand(); + // The relaunch policy comes from the launch context; the monitor re-checks the liveness of + // this process before relaunching, so a still-alive launcher is never duplicated. + MonitorLaunchContext.RelaunchPolicy relaunchPolicy = context.relaunchPolicy(); + spec.relaunchAlways = relaunchPolicy == MonitorLaunchContext.RelaunchPolicy.ALWAYS; + spec.relaunchOnCrash = relaunchPolicy == MonitorLaunchContext.RelaunchPolicy.ON_CRASH; + if (options.getInstanceId() != null) + spec.instanceId = options.getInstanceId().toString(); + spec.versionName = options.getVersionName(); + if (options.getGameDir() != null) + spec.gameDir = options.getGameDir().toString(); + spec.maxMemory = options.getMaxMemory(); + JavaRuntime java = options.getJava(); + if (java != null) { + spec.javaBinary = java.getBinary().toString(); + spec.javaVersion = java.getVersion(); + spec.javaArchitecture = java.getArchitecture().name(); + } + return spec; + } + + /// Dispatches one protocol message received from the monitor. + private static void dispatchProtocolMessage(String line, @Nullable ProcessListener listener, MonitorGameProcess process) { + String[] parts = line.split("\t", 3); + switch (parts[0]) { + case MonitorProtocol.TAG_LOG -> { + String log = parts.length > 2 ? parts[2] : ""; + if (listener != null) + listener.onLog(log, "1".equals(parts[1])); + } + case MonitorProtocol.TAG_EXIT -> { + if (listener != null && parts.length >= 3) { + try { + int exitCode = Integer.parseInt(parts[1]); + ProcessListener.ExitType exitType = ProcessListener.ExitType.valueOf(parts[2]); + + if (exitType == ProcessListener.ExitType.JVM_ERROR) + EventBus.EVENT_BUS.fireEvent(new JVMLaunchFailedEvent(MonitorClient.class, process)); + else if (exitType != ProcessListener.ExitType.NORMAL) + EventBus.EVENT_BUS.fireEvent(new ProcessExitedAbnormallyEvent(MonitorClient.class, process)); + EventBus.EVENT_BUS.fireEvent(new ProcessStoppedEvent(MonitorClient.class, process)); + + listener.onExit(exitCode, exitType); + } catch (IllegalArgumentException e) { + LOG.warning("Malformed exit message from HMCL monitor: " + line, e); + } + } + } + case MonitorProtocol.TAG_FATAL -> + LOG.error("HMCL monitor reported a failure: " + (parts.length > 1 ? parts[1] : "unknown")); + default -> LOG.warning("Unknown protocol message from HMCL monitor: " + line); + } + } + + /// Reads the handshake message `P\t\t` from the monitor. + /// + /// @throws IOException if the monitor exits or fails before the handshake, sends a malformed + /// message, or does not answer within [MonitorProtocol#HANDSHAKE_TIMEOUT] + private static String[] readHandshake(Process daemon, BufferedReader reader) throws IOException { + CompletableFuture future = new CompletableFuture<>(); + Thread waiter = Lang.thread(() -> { + try { + String line = reader.readLine(); + if (line == null) { + future.completeExceptionally(new IOException("The HMCL monitor exited before the handshake")); + return; + } + String[] parts = line.split("\t"); + if (MonitorProtocol.TAG_FATAL.equals(parts[0])) { + future.completeExceptionally(new IOException("The HMCL monitor failed to create the game process: " + + (parts.length > 1 ? parts[1] : "unknown reason"))); + } else if (!MonitorProtocol.TAG_PID.equals(parts[0]) || parts.length < 3) { + future.completeExceptionally(new IOException("Unexpected handshake message from HMCL monitor: " + line)); + } else { + future.complete(parts); + } + } catch (IOException e) { + future.completeExceptionally(e); + } + }, "hmcl-monitor-handshake", true); + + try { + return future.get(MonitorProtocol.HANDSHAKE_TIMEOUT, TimeUnit.MILLISECONDS); + } catch (TimeoutException e) { + daemon.destroy(); + throw new IOException("Timed out waiting for the HMCL monitor handshake", e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while waiting for the HMCL monitor handshake", e); + } catch (ExecutionException e) { + Throwable cause = e.getCause() != null ? e.getCause() : e; + throw new IOException(cause.getMessage(), cause); + } finally { + waiter.interrupt(); + } + } + + /// Starts the monitor process. Its stderr carries the protocol stream and is therefore piped; + /// its stdout, where the monitor's log output goes, is inherited for diagnostics. + private static Process startDaemonProcess(Path thisJar, Path specFile) throws IOException { + List commandline = JavaProcessLauncher.buildJavaCommandLine(thisJar, + "--monitor", specFile.toAbsolutePath().toString()); + LOG.info("Starting monitor process: " + JavaProcessLauncher.maskCommandLine(commandline)); + return new ProcessBuilder(commandline) + .directory(Paths.get("").toAbsolutePath().toFile()) + .redirectOutput(ProcessBuilder.Redirect.INHERIT) + .redirectError(ProcessBuilder.Redirect.PIPE) + .start(); + } +} diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java new file mode 100644 index 00000000000..c4264cf56be --- /dev/null +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java @@ -0,0 +1,103 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui 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 . + */ +package org.jackhuang.hmcl.monitor; + +import org.jackhuang.hmcl.util.platform.ProcessInfo; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +/// A view of the game process supervised by the HMCL monitor. +/// +///

Unlike [org.jackhuang.hmcl.util.platform.ManagedProcess], the game process is not spawned nor +/// owned by this JVM; only its id, command line and start time are known, learned from the monitor's +/// handshake. [stop] destroys the game process through its [ProcessHandle] and interrupts the +/// monitor protocol reader thread, so that a canceled launch is not mistaken for a crashed game. +/// +///

Instances also represent already-exited games when presenting the crash window of a relaunched +/// launcher; their [handle][#getHandle] may be unresolvable in that case. +@NotNullByDefault +public final class MonitorGameProcess implements ProcessInfo { + + private final @Nullable ProcessHandle handle; + private final long pid; + private final List commands; + private final long processStartTime; + private volatile @Nullable Thread readerThread; + + /// Creates a view of the monitor-supervised game process. + /// + /// @param handle the handle of the game process, or `null` if it cannot be resolved anymore + /// @param commands the command line of the game process + /// @param processStartTime the epoch millisecond at which the game process started + MonitorGameProcess(@Nullable ProcessHandle handle, List commands, long processStartTime) { + this.handle = handle; + this.pid = handle != null ? handle.pid() : -1; + this.commands = List.copyOf(commands); + this.processStartTime = processStartTime; + } + + /// Attaches the monitor protocol reader thread so that [stop] can interrupt it. + /// + /// @param readerThread the thread draining the monitor's protocol stream + void attachReaderThread(Thread readerThread) { + this.readerThread = readerThread; + } + + @Override + public long getPid() { + return pid; + } + + @Override + public @Nullable ProcessHandle getHandle() { + return handle; + } + + @Override + public List getCommands() { + return commands; + } + + @Override + public @Nullable String getClasspath() { + return null; + } + + @Override + public long getProcessStartTime() { + return processStartTime; + } + + @Override + public boolean isRunning() { + ProcessHandle handle = this.handle; + return handle != null && handle.isAlive(); + } + + @Override + public void stop() { + ProcessHandle handle = this.handle; + if (handle != null) + handle.destroy(); + Thread readerThread = this.readerThread; + if (readerThread != null) + readerThread.interrupt(); + } +} diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java new file mode 100644 index 00000000000..2ffcee75309 --- /dev/null +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java @@ -0,0 +1,70 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui 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 . + */ +package org.jackhuang.hmcl.monitor; + +import org.jackhuang.hmcl.launch.ProcessListener; +import org.jetbrains.annotations.NotNullByDefault; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +/// The line-based protocol spoken on the monitor process's stderr. +/// +///

Messages are UTF-8 encoded, one per line, with tab-separated fields: +///

    +///
  • {@code P\t\t} — handshake, sent once the game process has been created;
  • +///
  • {@code L\t\t} — one line of game output;
  • +///
  • {@code E\t\t} — the game process has exited;
  • +///
  • {@code F\t} — a fatal error, the game process could not be created.
  • +///
+@NotNullByDefault +public final class MonitorProtocol { + + /// The charset of the protocol stream. + public static final Charset CHARSET = StandardCharsets.UTF_8; + + /// How long to wait for the monitor's handshake before giving up, in milliseconds. + static final long HANDSHAKE_TIMEOUT = 20_000; + + /// Message tag of the handshake message. + static final String TAG_PID = "P"; + /// Message tag of a game output line. + static final String TAG_LOG = "L"; + /// Message tag of the game exit message. + static final String TAG_EXIT = "E"; + /// Message tag of a fatal error message. + static final String TAG_FATAL = "F"; + + private MonitorProtocol() { + } + + /// Builds the handshake message carrying the game process id and its start time. + static String pidMessage(long pid, long processStartTime) { + return TAG_PID + "\t" + pid + "\t" + processStartTime; + } + + /// Builds a game output line message. + static String logMessage(boolean isErrorStream, String line) { + return TAG_LOG + "\t" + (isErrorStream ? 1 : 0) + "\t" + line; + } + + /// Builds the game exit message carrying the exit code and its classified type. + static String exitMessage(int exitCode, ProcessListener.ExitType exitType) { + return TAG_EXIT + "\t" + exitCode + "\t" + exitType.name(); + } +} diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java new file mode 100644 index 00000000000..06a915aa4b0 --- /dev/null +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java @@ -0,0 +1,250 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui 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 . + */ +package org.jackhuang.hmcl.monitor; + +import org.jackhuang.hmcl.launch.ExitWaiter; +import org.jackhuang.hmcl.launch.ProcessListener; +import org.jackhuang.hmcl.launch.StreamPump; +import org.jackhuang.hmcl.util.Lang; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.io.JarUtils; +import org.jackhuang.hmcl.util.platform.JavaProcessLauncher; +import org.jackhuang.hmcl.util.platform.ManagedProcess; +import org.jackhuang.hmcl.util.platform.SystemUtils; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.io.*; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// The monitor process role of the monitor feature: creating the game process, supervising it until +/// it exits, and reporting the result. +/// +///

The monitor has no user interface. It owns the game process, so it always knows the real exit +/// code even after the main launcher process has exited. Game output is teed into a session log +/// file and forwarded to the main launcher process over [the protocol][MonitorProtocol]; after the +/// game exits, the result is written to a result file, the post-exit command is run, and HMCL is +/// relaunched if the launch options ask for it and the main launcher process is already gone. +@NotNullByDefault +public final class MonitorSupervisor { + + private MonitorSupervisor() { + } + + /// Handles the `--monitor` entry point. + /// + /// @param args the process arguments + /// @return whether the arguments were consumed; the caller should exit afterward, since + /// [MonitorSupervisor#start] blocks until the game process has exited + public static boolean processArguments(String[] args) { + if (args.length >= 1 && args[0].equals("--monitor")) { + if (args.length != 2) { + LOG.error("Usage: --monitor "); + } else { + try { + start(Path.of(args[1])); + } catch (IOException | InterruptedException e) { + LOG.error("Failed to supervise the game process", e); + } + } + return true; + } + return false; + } + + /// Reads the [ProcessSpec] from `specPath`, creates the process it describes, supervises it + /// until it exits, and reports the result. + /// + /// @param specPath the file containing the [ProcessSpec]; deleted after the process is created + /// @throws IOException if the spec file is malformed or the game process cannot be created + /// @throws InterruptedException if interrupted while waiting for the game process + public static void start(Path specPath) throws IOException, InterruptedException { + LOG.info("*** HMCL MONITOR ***"); + + // stdout is used by log output so using stderr for protocol + PrintStream protocol = new PrintStream(new FileOutputStream(FileDescriptor.err), true, MonitorProtocol.CHARSET); + + ProcessSpec spec = JsonUtils.fromJsonFile(specPath, ProcessSpec.class); + if (spec == null || spec.command == null || spec.command.isEmpty() || spec.encoding == null) + throw new IOException("Malformed process spec file " + specPath); + Files.delete(specPath); + + ProcessBuilder pb = new ProcessBuilder(spec.command); + @Nullable String directory = spec.directory; + if (directory != null && !directory.isEmpty()) + pb.directory(new File(directory)); + if (spec.environment != null) + pb.environment().putAll(spec.environment); + if (spec.inheritStdin) + pb.redirectInput(ProcessBuilder.Redirect.INHERIT); + + long fallbackStartTime = System.currentTimeMillis(); + Process game = pb.start(); + long processStartTime = game.info().startInstant().map(Instant::toEpochMilli).orElse(fallbackStartTime); + + LOG.info("Game process created, pid " + game.pid() + ", spec " + specPath); + protocol.println(MonitorProtocol.pidMessage(game.pid(), processStartTime)); + + Path logFile = Files.createTempFile("hmcl-monitor-game-", ".log"); + + /// The monitor owns the game process, so the regular [ManagedProcess] applies, and the exit + /// classification is performed by the very same [ExitWaiter] the direct launch path uses. + ManagedProcess gameProcess = new ManagedProcess(game, spec.command); + + // Forward one game output line to the log file, the managed process (feeding the exit + // classification) and the protocol stream. A broken protocol stream (the main launcher + // process exited early) is not fatal: only the forwarding stops, the log file keeps + // collecting the remaining output. + try (BufferedWriter logWriter = Files.newBufferedWriter(logFile, MonitorProtocol.CHARSET)) { + startMonitors(gameProcess, logWriter, protocol, spec, processStartTime, logFile); + } + } + + static private void startMonitors(ManagedProcess gameProcess, BufferedWriter logWriter, PrintStream protocol, + ProcessSpec spec, long processStartTime, Path logFile) throws InterruptedException { + AtomicBoolean parentGone = new AtomicBoolean(false); + class LineSink { + void accept(String line, boolean isErrorStream) { + try { + synchronized (logWriter) { + logWriter.write(line); + logWriter.newLine(); + logWriter.flush(); + } + } catch (IOException e) { + LOG.warning("Failed to write the session log", e); + } + gameProcess.addLine(line); + if (!parentGone.get()) { + protocol.println(MonitorProtocol.logMessage(isErrorStream, line)); + if (protocol.checkError()) + parentGone.set(true); + } + } + } + LineSink sink = new LineSink(); + + Charset encoding = Charset.forName(Objects.requireNonNullElse(spec.encoding, Charset.defaultCharset().name())); + Thread stdoutPump = Lang.thread(new StreamPump(gameProcess.getProcess().getInputStream(), + line -> sink.accept(line, false), encoding), "monitor-stdout-pump", true); + Thread stderrPump = Lang.thread(new StreamPump(gameProcess.getProcess().getErrorStream(), + line -> sink.accept(line, true), encoding), "monitor-stderr-pump", true); + gameProcess.addRelatedThread(stdoutPump); + gameProcess.addRelatedThread(stderrPump); + + Thread exitWaiter = Lang.thread(new ExitWaiter(gameProcess, List.of(stdoutPump, stderrPump), (exitCode, exitType) -> { + LOG.info("Game exited with code " + exitCode + "(" + Integer.toHexString(exitCode) + "), type " + exitType); + protocol.println(MonitorProtocol.exitMessage(exitCode, exitType)); + + runPostExitCommand(spec); + + boolean crashed = exitType != ProcessListener.ExitType.NORMAL; + try { + Path resultFile = writeResult(spec, gameProcess.getPid(), processStartTime, exitCode, exitType, logFile); + boolean relaunch = spec.relaunchAlways || (spec.relaunchOnCrash && crashed); + if (relaunch && !isParentAlive()) { + relaunch(resultFile, crashed); + } + } catch (IOException e) { + LOG.error("Failed to write the monitor result file or relaunch the launcher", e); + } + // When the main launcher process is still alive it has consumed the events itself and + // presents the crash window; the result file is left behind for debugging only. + }), "exit-waiter", true); + + // Keep the monitor process alive until the game has exited and been reported; the caller + // exits the process right after this method returns. + exitWaiter.join(); + } + + private static void runPostExitCommand(ProcessSpec spec) { + if (spec.postExitCommand == null || spec.postExitCommand.isEmpty()) + return; + try { + // The direct launch path runs the post-exit command in the game directory, which is + // not necessarily the game process's working directory when version isolation is on. + ProcessBuilder builder = new ProcessBuilder(spec.postExitCommand); + if (spec.gameDir != null && !spec.gameDir.isEmpty()) + builder.directory(new File(spec.gameDir)); + else if (spec.directory != null && !spec.directory.isEmpty()) + builder.directory(new File(spec.directory)); + if (spec.environment != null) + builder.environment().putAll(spec.environment); + SystemUtils.callExternalProcess(builder); + } catch (Throwable e) { + LOG.warning("An Exception happened while running exit command.", e); + } + } + + /// Writes the [ResultSpec] consumed by a relaunched launcher to a temp file. + private static Path writeResult(ProcessSpec spec, long pid, long processStartTime, int exitCode, + ProcessListener.ExitType exitType, Path logFile) throws IOException { + Path resultFile = Files.createTempFile("hmcl-monitor-result-", ".json"); + ResultSpec result = new ResultSpec(); + result.pid = pid; + result.processStartTime = processStartTime; + result.exitCode = exitCode; + result.exitType = exitType.name(); + result.logFile = logFile.toString(); + result.instanceId = spec.instanceId; + result.versionName = spec.versionName; + result.gameDir = spec.gameDir; + result.maxMemory = spec.maxMemory; + result.javaBinary = spec.javaBinary; + result.javaVersion = spec.javaVersion; + result.javaArchitecture = spec.javaArchitecture; + result.commands = spec.command; + JsonUtils.writeToJsonFile(resultFile, result); + return resultFile; + } + + /// Returns whether the main launcher process that spawned this daemon is still alive. + /// + ///

On POSIX systems a reparented process's parent is the init process, which also means + /// the original parent is gone. + private static boolean isParentAlive() { + Optional parent = ProcessHandle.current().parent(); + if (parent.isEmpty()) + return false; + ProcessHandle parentHandle = parent.get(); + return parentHandle.pid() != 1 && parentHandle.isAlive(); + } + + /// Starts a new HMCL main process. Fire-and-forget; the current monitor process exits right after. + private static void relaunch(Path resultFile, boolean crashed) throws IOException { + Path thisJar = JarUtils.thisJarPath(); + if (thisJar == null) { + LOG.warning("Failed to find the current HMCL jar, cannot relaunch the launcher"); + return; + } + if (crashed) { + JavaProcessLauncher.startJava(thisJar, "--crash-report", resultFile.toAbsolutePath().toString()); + } else { + JavaProcessLauncher.startJava(thisJar); + } + } +} diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java new file mode 100644 index 00000000000..7ae71e3789a --- /dev/null +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java @@ -0,0 +1,71 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui 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 . + */ +package org.jackhuang.hmcl.monitor; + +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.Map; + +/// The specification of the game process to supervise, exchanged with the monitor process as JSON. +/// +/// ```json +/// { +/// "command": ["javaw", "-Xmx4G", "net.minecraft.client.main.Main"], +/// "directory": "D:\\Game\\.minecraft", +/// "environment": { "APPDATA": "C:\\Users\\user\\AppData\\Roaming" }, +/// "encoding": "UTF-8", +/// "relaunchAlways": false, +/// "relaunchOnCrash": true +/// } +/// ``` +@NotNullByDefault +final class ProcessSpec { + /// The command line to execute, passed to [ProcessBuilder] verbatim. + @Nullable List command; + /// The working directory of the process, or `null` to inherit the monitor's working directory. + @Nullable String directory; + /// Environment variables overlaid on the monitor's environment, or `null` to keep it unchanged. + @Nullable Map environment; + /// The charset of the game process output; required. + @Nullable String encoding; + /// Whether the game process should inherit the monitor's standard input, matching the direct + /// launch path's behavior when no process listener is present. + boolean inheritStdin; + /// The post-exit command to run after the game exits, already tokenized, or `null`. + @Nullable List postExitCommand; + /// Whether to relaunch the launcher after the game exits unconditionally. + boolean relaunchAlways; + /// Whether to relaunch the launcher after the game exits abnormally. + boolean relaunchOnCrash; + /// The id of the launched instance, or `null` when unknown. + @Nullable String instanceId; + /// The display name of the launched version, or `null` when unknown. + @Nullable String versionName; + /// The game directory of the launched instance, or `null` when unknown. + @Nullable String gameDir; + /// The maximum memory of the game process in MiB, or `null` when unset. + @Nullable Integer maxMemory; + /// The binary path of the Java runtime the game runs on, or `null` when unknown. + @Nullable String javaBinary; + /// The version string of the Java runtime the game runs on, or `null` when unknown. + @Nullable String javaVersion; + /// The architecture name of the Java runtime the game runs on, or `null` when unknown. + @Nullable String javaArchitecture; +} diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ResultSpec.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ResultSpec.java new file mode 100644 index 00000000000..73f4191afaf --- /dev/null +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ResultSpec.java @@ -0,0 +1,56 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui 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 . + */ +package org.jackhuang.hmcl.monitor; + +import org.jackhuang.hmcl.launch.ProcessListener; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +/// The result of a supervised game process, written by the monitor and consumed by a relaunched +/// launcher process through the `--crash-report` argument. +@NotNullByDefault +public final class ResultSpec { + /// The process id of the game process; `-1` when unknown. + long pid; + /// The epoch millisecond at which the game process started. + long processStartTime; + /// The exit code of the game process. + @Nullable Integer exitCode; + /// The name of the [ProcessListener.ExitType] of the game process. + @Nullable String exitType; + /// The session log file collecting the game output, or `null` when unavailable. + @Nullable String logFile; + /// The id of the launched instance, or `null` when unknown. + @Nullable String instanceId; + /// The display name of the launched version, or `null` when unknown. + @Nullable String versionName; + /// The game directory of the launched instance, or `null` when unknown. + @Nullable String gameDir; + /// The maximum memory of the game process in MiB, or `null` when unset. + @Nullable Integer maxMemory; + /// The binary path of the Java runtime the game runs on, or `null` when unknown. + @Nullable String javaBinary; + /// The version string of the Java runtime the game runs on, or `null` when unknown. + @Nullable String javaVersion; + /// The architecture name of the Java runtime the game runs on, or `null` when unknown. + @Nullable String javaArchitecture; + /// The command line of the game process, or `null` when unknown. + @Nullable List commands; +} diff --git a/settings.gradle.kts b/settings.gradle.kts index a1a710b39c0..4a347b87c9e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -2,7 +2,8 @@ rootProject.name = "HMCL3" include( "HMCL", "HMCLCore", - "HMCLBoot" + "HMCLBoot", + "HMCLMonitor" ) val minecraftLibraries = listOf("HMCLTransformerDiscoveryService", "HMCLMultiMCBootstrap") From 95f95f8a7cb80b7f67f52d601709f1373e35f158 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B1=BC=E5=89=8D=E4=B8=89=E7=99=BE?= Date: Wed, 30 Sep 2026 19:19:14 +0800 Subject: [PATCH 04/44] =?UTF-8?q?feat:=20=E6=B8=B8=E6=88=8F=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=E9=80=80=E5=87=BA=E5=90=8E=E7=94=B1=20monitor=20?= =?UTF-8?q?=E9=87=8D=E6=8B=89=20HMCL=20=E5=B9=B6=E5=A4=8D=E7=94=A8?= =?UTF-8?q?=E5=B4=A9=E6=BA=83=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/jackhuang/hmcl/Launcher.java | 7 + .../hmcl/monitor/MonitorCrashReporter.java | 247 ++++++++++++++++++ .../hmcl/setting/GameDirectoryManager.java | 18 ++ 3 files changed, 272 insertions(+) create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java b/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java index 46872d8c4ed..43d33fb27b1 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java @@ -34,6 +34,7 @@ import javafx.stage.Screen; import javafx.stage.Stage; import javafx.util.Duration; +import org.jackhuang.hmcl.monitor.MonitorCrashReporter; import org.jackhuang.hmcl.game.HMCLCacheRepository; import org.jackhuang.hmcl.setting.*; import org.jackhuang.hmcl.task.AsyncTaskExecutor; @@ -149,6 +150,12 @@ public void start(Stage primaryStage) { WindowsNativeUtils.installWindowsAppUserModelRelaunchProperties(primaryStage); primaryStage.show(); + + // This process may have been relaunched by the HMCL monitor to present the crash + // window of a supervised game process. + Path pendingCrashReport = MonitorCrashReporter.processArguments(getParameters().getRaw().toArray(new String[0])); + if (pendingCrashReport != null) + MonitorCrashReporter.show(pendingCrashReport); }); } catch (Throwable e) { CRASH_REPORTER.uncaughtException(Thread.currentThread(), e); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java new file mode 100644 index 00000000000..c4575cc116d --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java @@ -0,0 +1,247 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui 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 . + */ +package org.jackhuang.hmcl.monitor; + +import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableValue; +import org.jackhuang.hmcl.game.DefaultGameRepositorySnapshot; +import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.HMCLGameInstance; +import org.jackhuang.hmcl.game.HMCLGameRepository; +import org.jackhuang.hmcl.game.Log; +import org.jackhuang.hmcl.game.LaunchOptions; +import org.jackhuang.hmcl.java.JavaInfo; +import org.jackhuang.hmcl.java.JavaRuntime; +import org.jackhuang.hmcl.launch.ProcessListener; +import org.jackhuang.hmcl.setting.GameDirectoryManager; +import org.jackhuang.hmcl.ui.GameCrashWindow; +import org.jackhuang.hmcl.util.Log4jLevel; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.platform.Architecture; +import org.jackhuang.hmcl.util.platform.OperatingSystem; +import org.jackhuang.hmcl.util.platform.Platform; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +import static javafx.application.Platform.runLater; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// The relaunched launcher role of the monitor feature: presenting the crash window of a supervised +/// game process, reusing the regular JavaFX user interface. +@NotNullByDefault +public final class MonitorCrashReporter { + + private MonitorCrashReporter() { + } + + /// Handles the `--crash-report` argument of a launcher process relaunched by the monitor. + /// + /// @param args the process arguments + /// @return the path of the monitor result file to present, or `null` if the arguments do not + /// request a crash report + public static @Nullable Path processArguments(String[] args) { + if (args.length == 2 && args[0].equals("--crash-report")) + return Path.of(args[1]); + return null; + } + + /// Shows the game crash window for the given monitor result file. + /// + ///

Must be called on the JavaFX application thread after the main window has been set up. + /// The result file is deleted once it has been consumed. When the launched instance cannot be + /// resolved even after every game repository has finished loading, the crash window is skipped; + /// the session log file remains on disk for inspection. + /// + /// @param resultFile the result file written by the monitor + public static void show(Path resultFile) { + ResultSpec result; + try { + result = JsonUtils.fromJsonFile(resultFile, ResultSpec.class); + } catch (IOException e) { + LOG.warning("Failed to read the monitor result file " + resultFile, e); + return; + } + try { + Files.deleteIfExists(resultFile); + } catch (IOException e) { + LOG.warning("Failed to delete the monitor result file " + resultFile, e); + } + if (result == null) + return; + + List repositories = GameDirectoryManager.getOrCreateAllRepositories(); + HMCLGameInstance instance = findInstance(repositories, result); + if (instance != null) { + presentCrashWindow(result, instance); + return; + } + + // The relaunched launcher shows its main window before the game repositories have finished + // loading their snapshots asynchronously, so the instance may not be resolvable yet; wait + // for the pending loads and try again when each repository publishes its snapshot. + List pending = repositories.stream() + .filter(repository -> !repository.isLoaded()) + .collect(Collectors.toList()); + if (pending.isEmpty()) { + LOG.warning("Cannot resolve the launched instance " + result.instanceId + " (" + + describeRepositories(repositories) + "), skip showing the crash window." + + " The session log is at " + result.logFile); + return; + } + + LOG.info("Waiting for " + pending.size() + " game repositories to load before showing the crash window"); + AtomicInteger pendingCount = new AtomicInteger(pending.size()); + AtomicBoolean handled = new AtomicBoolean(false); + for (HMCLGameRepository repository : pending) { + ChangeListener listener = new ChangeListener<>() { + @Override + public void changed(ObservableValue observable, + DefaultGameRepositorySnapshot oldValue, DefaultGameRepositorySnapshot newValue) { + repository.snapshotProperty().removeListener(this); + if (handled.get()) + return; + + HMCLGameInstance resolved = findInstance(repositories, result); + if (resolved != null) { + if (handled.compareAndSet(false, true)) + runLater(() -> presentCrashWindow(result, resolved)); + } else if (pendingCount.decrementAndGet() == 0 && handled.compareAndSet(false, true)) { + LOG.warning("Cannot resolve the launched instance " + result.instanceId + " (" + + describeRepositories(repositories) + "), skip showing the crash window." + + " The session log is at " + result.logFile); + } + } + }; + repository.snapshotProperty().addListener(listener); + } + } + + /// Builds and shows the crash window from the monitor result and the resolved instance. + private static void presentCrashWindow(ResultSpec result, HMCLGameInstance instance) { + try { + List commands = result.commands != null ? result.commands : List.of(); + ProcessHandle gameHandle = result.pid > 0 ? ProcessHandle.of(result.pid).orElse(null) : null; + MonitorGameProcess process = new MonitorGameProcess(gameHandle, commands, result.processStartTime); + + ProcessListener.ExitType exitType = ProcessListener.ExitType.APPLICATION_ERROR; + if (result.exitType != null) { + try { + exitType = ProcessListener.ExitType.valueOf(result.exitType); + } catch (IllegalArgumentException e) { + LOG.warning("Unknown exit type in the monitor result file: " + result.exitType); + } + } + + LaunchOptions launchOptions = rebuildLaunchOptions(result); + List logs = readSessionLogs(result.logFile); + + new GameCrashWindow(process, exitType, instance, launchOptions, logs).show(); + } catch (Throwable e) { + LOG.warning("Failed to show the crash window for the relaunched launcher", e); + } + } + + /// Describes the given repositories with their loaded instance counts, for diagnostics. + private static String describeRepositories(List repositories) { + return repositories.stream() + .map(repository -> repository.getSnapshot().getInstanceCount() + " instances, loaded=" + + repository.isLoaded()) + .collect(Collectors.joining("; ", "[", "]")); + } + + /// Rebuilds a [LaunchOptions] carrying the display-relevant fields recorded by the monitor. + /// + /// The [JavaRuntime] is guaranteed to be present, since the crash window dereferences it for + /// display; a failure to rebuild the recorded one falls back to the current runtime. + private static LaunchOptions rebuildLaunchOptions(ResultSpec result) { + LaunchOptions.Builder builder = new LaunchOptions.Builder(); + if (result.gameDir != null) { + try { + builder.setGameDir(Path.of(result.gameDir)); + } catch (IllegalArgumentException e) { + LOG.warning("Malformed game directory in the monitor result file: " + result.gameDir); + } + } + if (result.versionName != null) + builder.setVersionName(result.versionName); + if (result.maxMemory != null) + builder.setMaxMemory(result.maxMemory); + + JavaRuntime java = null; + if (result.javaBinary != null && result.javaVersion != null && result.javaArchitecture != null) { + try { + Architecture architecture = Architecture.parseArchName(result.javaArchitecture); + JavaInfo info = new JavaInfo.Builder( + Platform.getPlatform(OperatingSystem.CURRENT_OS, architecture), result.javaVersion).build(); + java = JavaRuntime.of(Path.of(result.javaBinary), info, false); + } catch (Throwable e) { + LOG.warning("Failed to rebuild the Java runtime from the monitor result file", e); + } + } + if (java == null) { + LOG.warning("Falling back to the current Java runtime for the crash window display"); + java = JavaRuntime.getDefault(); + } + builder.setJava(java); + return builder.create(); + } + + /// Reads the session log file written by the monitor into [Log] entries, guessing the level of + /// each line. Returns an empty list when the log file is missing or unreadable. + private static List readSessionLogs(@Nullable String logFile) { + if (logFile == null) + return List.of(); + try { + return Files.readAllLines(Path.of(logFile), MonitorProtocol.CHARSET).stream() + .map(line -> new Log(line, Log4jLevel.guessLevel(line))) + .collect(Collectors.toList()); + } catch (IOException e) { + LOG.warning("Failed to read the session log file " + logFile, e); + return List.of(); + } + } + + /// Resolves the launched instance by its id across the given game directories. + private static @Nullable HMCLGameInstance findInstance(List repositories, ResultSpec result) { + if (result.instanceId == null) + return null; + + GameInstanceID id; + try { + id = new GameInstanceID(result.instanceId); + } catch (IllegalArgumentException e) { + LOG.warning("Malformed instance id in the monitor result file: " + result.instanceId); + return null; + } + + for (HMCLGameRepository repository : repositories) { + HMCLGameInstance instance = repository.findInstance(id); + if (instance != null) + return instance; + } + return null; + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectoryManager.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectoryManager.java index 1c3e8c98969..dba01254247 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectoryManager.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectoryManager.java @@ -30,6 +30,7 @@ import org.jackhuang.hmcl.util.i18n.LocalizedText; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; import org.jetbrains.annotations.UnknownNullability; import org.jetbrains.annotations.UnmodifiableView; @@ -278,6 +279,23 @@ private static HMCLGameRepository getOrCreateRepository(GameDirectory gameDirect return repositories.computeIfAbsent(gameDirectory, HMCLGameRepository::new); } + /// Creates repositories for every merged game directory and returns them, starting the initial + /// refresh of the ones that have not loaded yet. + /// + ///

Used for lookups that are not tied to the selected game directory, e.g. resolving an + /// instance id recorded by the HMCL monitor. The selected repository's refresh is managed by the + /// selection flow and is not started again here. + public static @Unmodifiable List getOrCreateAllRepositories() { + List result = new ArrayList<>(); + for (GameDirectory directory : mergedGameDirectoriesUnmodifiable) { + HMCLGameRepository repository = getOrCreateRepository(directory); + if (repository != selectedRepository.get() && !repository.isLoaded()) + repository.refreshAsync().start(); + result.add(repository); + } + return result; + } + /// Adds a game directory to the per-workspace store. public static void addLocalGameDirectory(GameDirectory gameDirectory) { if (SettingsManager.isLocalGameDirectoriesReadOnly()) { From 1cf1ac9ad4efcf8cc5d5b97818014c5f54e7b9a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B1=BC=E5=89=8D=E4=B8=89=E7=99=BE?= Date: Wed, 30 Sep 2026 21:27:39 +0800 Subject: [PATCH 05/44] =?UTF-8?q?chore:=20=E6=B8=85=E7=90=86=E4=BB=A3?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/monitor/MonitorCrashReporter.java | 5 ++++- .../jackhuang/hmcl/monitor/MonitorClient.java | 8 +------- .../jackhuang/hmcl/monitor/MonitorProtocol.java | 5 +---- .../hmcl/monitor/MonitorSupervisor.java | 17 +++++++++-------- 4 files changed, 15 insertions(+), 20 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java index c4575cc116d..05669d53528 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java @@ -104,7 +104,7 @@ public static void show(Path resultFile) { // for the pending loads and try again when each repository publishes its snapshot. List pending = repositories.stream() .filter(repository -> !repository.isLoaded()) - .collect(Collectors.toList()); + .toList(); if (pending.isEmpty()) { LOG.warning("Cannot resolve the launched instance " + result.instanceId + " (" + describeRepositories(repositories) + "), skip showing the crash window." @@ -157,6 +157,9 @@ private static void presentCrashWindow(ResultSpec result, HMCLGameInstance insta LaunchOptions launchOptions = rebuildLaunchOptions(result); List logs = readSessionLogs(result.logFile); + if (result.logFile != null) { + Files.delete(Path.of(result.logFile)); + } new GameCrashWindow(process, exitType, instance, launchOptions, logs).show(); } catch (Throwable e) { diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java index 3e735fdf5cf..b69161b2ac0 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java @@ -31,7 +31,6 @@ import org.jackhuang.hmcl.util.platform.JavaProcessLauncher; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; -import org.jetbrains.annotations.UnknownNullability; import java.io.BufferedReader; import java.io.IOException; @@ -182,8 +181,6 @@ else if (exitType != ProcessListener.ExitType.NORMAL) } } } - case MonitorProtocol.TAG_FATAL -> - LOG.error("HMCL monitor reported a failure: " + (parts.length > 1 ? parts[1] : "unknown")); default -> LOG.warning("Unknown protocol message from HMCL monitor: " + line); } } @@ -202,10 +199,7 @@ private static String[] readHandshake(Process daemon, BufferedReader reader) thr return; } String[] parts = line.split("\t"); - if (MonitorProtocol.TAG_FATAL.equals(parts[0])) { - future.completeExceptionally(new IOException("The HMCL monitor failed to create the game process: " - + (parts.length > 1 ? parts[1] : "unknown reason"))); - } else if (!MonitorProtocol.TAG_PID.equals(parts[0]) || parts.length < 3) { + if (!MonitorProtocol.TAG_PID.equals(parts[0]) || parts.length < 3) { future.completeExceptionally(new IOException("Unexpected handshake message from HMCL monitor: " + line)); } else { future.complete(parts); diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java index 2ffcee75309..9e43c57cd38 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java @@ -29,8 +29,7 @@ ///

    ///
  • {@code P\t\t} — handshake, sent once the game process has been created;
  • ///
  • {@code L\t\t} — one line of game output;
  • -///
  • {@code E\t\t} — the game process has exited;
  • -///
  • {@code F\t} — a fatal error, the game process could not be created.
  • +///
  • {@code E\t\t} — the game process has exited.
  • ///
@NotNullByDefault public final class MonitorProtocol { @@ -47,8 +46,6 @@ public final class MonitorProtocol { static final String TAG_LOG = "L"; /// Message tag of the game exit message. static final String TAG_EXIT = "E"; - /// Message tag of a fatal error message. - static final String TAG_FATAL = "F"; private MonitorProtocol() { } diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java index 06a915aa4b0..60d69b13c7c 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java @@ -111,8 +111,8 @@ public static void start(Path specPath) throws IOException, InterruptedException Path logFile = Files.createTempFile("hmcl-monitor-game-", ".log"); - /// The monitor owns the game process, so the regular [ManagedProcess] applies, and the exit - /// classification is performed by the very same [ExitWaiter] the direct launch path uses. + // The monitor owns the game process, so the regular [ManagedProcess] applies, and the exit + // classification is performed by the very same [ExitWaiter] the direct launch path uses. ManagedProcess gameProcess = new ManagedProcess(game, spec.command); // Forward one game output line to the log file, the managed process (feeding the exit @@ -164,16 +164,17 @@ void accept(String line, boolean isErrorStream) { boolean crashed = exitType != ProcessListener.ExitType.NORMAL; try { - Path resultFile = writeResult(spec, gameProcess.getPid(), processStartTime, exitCode, exitType, logFile); + @Nullable Path resultFile = null; + if (crashed) { + resultFile = writeResult(spec, gameProcess.getPid(), processStartTime, exitCode, exitType, logFile); + } boolean relaunch = spec.relaunchAlways || (spec.relaunchOnCrash && crashed); if (relaunch && !isParentAlive()) { - relaunch(resultFile, crashed); + relaunch(resultFile); } } catch (IOException e) { LOG.error("Failed to write the monitor result file or relaunch the launcher", e); } - // When the main launcher process is still alive it has consumed the events itself and - // presents the crash window; the result file is left behind for debugging only. }), "exit-waiter", true); // Keep the monitor process alive until the game has exited and been reported; the caller @@ -235,13 +236,13 @@ private static boolean isParentAlive() { } /// Starts a new HMCL main process. Fire-and-forget; the current monitor process exits right after. - private static void relaunch(Path resultFile, boolean crashed) throws IOException { + private static void relaunch(@Nullable Path resultFile) throws IOException { Path thisJar = JarUtils.thisJarPath(); if (thisJar == null) { LOG.warning("Failed to find the current HMCL jar, cannot relaunch the launcher"); return; } - if (crashed) { + if (resultFile != null) { // crashed JavaProcessLauncher.startJava(thisJar, "--crash-report", resultFile.toAbsolutePath().toString()); } else { JavaProcessLauncher.startJava(thisJar); From 958b4e4627e3352b4c080dfbb340f99fab030ff1 Mon Sep 17 00:00:00 2001 From: fishdry Date: Thu, 1 Oct 2026 21:27:11 +0800 Subject: [PATCH 06/44] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=89=8B?= =?UTF-8?q?=E5=8A=A8=E5=8F=96=E6=B6=88=E4=BC=9A=E5=BC=B9=E5=87=BA=E5=B4=A9?= =?UTF-8?q?=E6=BA=83=E7=AA=97=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/monitor/MonitorCrashReporter.java | 2 +- .../hmcl/util/platform/ProcessInfo.java | 5 +- .../jackhuang/hmcl/monitor/MonitorClient.java | 7 +-- .../hmcl/monitor/MonitorGameProcess.java | 51 +++++++++++++------ .../hmcl/monitor/MonitorSupervisor.java | 46 ++++++++++++++--- .../jackhuang/hmcl/monitor/ProcessSpec.java | 5 ++ 6 files changed, 88 insertions(+), 28 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java index 05669d53528..fe69c1042b0 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java @@ -144,7 +144,7 @@ private static void presentCrashWindow(ResultSpec result, HMCLGameInstance insta try { List commands = result.commands != null ? result.commands : List.of(); ProcessHandle gameHandle = result.pid > 0 ? ProcessHandle.of(result.pid).orElse(null) : null; - MonitorGameProcess process = new MonitorGameProcess(gameHandle, commands, result.processStartTime); + MonitorGameProcess process = new MonitorGameProcess(gameHandle, commands, result.processStartTime, null); ProcessListener.ExitType exitType = ProcessListener.ExitType.APPLICATION_ERROR; if (result.exitType != null) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ProcessInfo.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ProcessInfo.java index 0a7a4dff76b..86f0b8f9329 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ProcessInfo.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ProcessInfo.java @@ -49,7 +49,8 @@ public interface ProcessInfo { /// Destroys the game process. /// - ///

Implementations that monitor the process with extra threads must also interrupt them, so - /// that a cancelled launch is not mistaken for a crashed game. + ///

The exit of a manually stopped game must be recognizable as a cancellation by whoever + /// classifies the exit, so that a cancelled launch is not mistaken for a crashed game; the + /// mechanism depends on how the implementation supervises the process. void stop(); } diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java index b69161b2ac0..64008e1f884 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java @@ -100,13 +100,15 @@ public static MonitorGameProcess launchNewMonitor(MonitorLaunchContext context) } ProcessHandle gameHandle = ProcessHandle.of(pid).orElse(null); - MonitorGameProcess process = new MonitorGameProcess(gameHandle, context.builder().command(), processStartTime); + MonitorGameProcess process = new MonitorGameProcess(gameHandle, context.builder().command(), processStartTime, specFile); ProcessListener listener = context.listener(); if (listener != null) listener.setProcess(process); - Thread readerThread = Lang.thread(() -> { + // The reader thread keeps draining the protocol stream until the monitor exits, so that the + // exit message of a canceled launch is still dispatched and classified as interrupted. + Lang.thread(() -> { try { String line; while ((line = reader.readLine()) != null) { @@ -116,7 +118,6 @@ public static MonitorGameProcess launchNewMonitor(MonitorLaunchContext context) LOG.error("Failed to read from the HMCL monitor", e); } }, "hmcl-monitor-reader", true); - process.attachReaderThread(readerThread); return process; } diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java index c4264cf56be..48936dc6ac6 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java @@ -17,21 +17,28 @@ */ package org.jackhuang.hmcl.monitor; +import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.platform.ProcessInfo; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; +import java.io.IOException; +import java.nio.file.Path; import java.util.List; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + /// A view of the game process supervised by the HMCL monitor. /// ///

Unlike [org.jackhuang.hmcl.util.platform.ManagedProcess], the game process is not spawned nor /// owned by this JVM; only its id, command line and start time are known, learned from the monitor's -/// handshake. [stop] destroys the game process through its [ProcessHandle] and interrupts the -/// monitor protocol reader thread, so that a canceled launch is not mistaken for a crashed game. +/// handshake. [stop] rewrites the spec file to mark the game process as canceled before destroying +/// it through its [ProcessHandle], so the monitor reports the exit as interrupted rather than a +/// crash. /// ///

Instances also represent already-exited games when presenting the crash window of a relaunched -/// launcher; their [handle][#getHandle] may be unresolvable in that case. +/// launcher; their [handle][#getHandle] may be unresolvable and their spec file is unknown in that +/// case. @NotNullByDefault public final class MonitorGameProcess implements ProcessInfo { @@ -39,25 +46,23 @@ public final class MonitorGameProcess implements ProcessInfo { private final long pid; private final List commands; private final long processStartTime; - private volatile @Nullable Thread readerThread; + /// The file the spec was exchanged with the monitor through, rewritten by [stop] to mark the + /// cancellation, or `null` when the game process is a dead one presented by a relaunched launcher. + private final @Nullable Path specFile; /// Creates a view of the monitor-supervised game process. /// /// @param handle the handle of the game process, or `null` if it cannot be resolved anymore /// @param commands the command line of the game process /// @param processStartTime the epoch millisecond at which the game process started - MonitorGameProcess(@Nullable ProcessHandle handle, List commands, long processStartTime) { + /// @param specFile the file the spec was exchanged with the monitor through, or `null` if unknown + MonitorGameProcess(@Nullable ProcessHandle handle, List commands, long processStartTime, + @Nullable Path specFile) { this.handle = handle; this.pid = handle != null ? handle.pid() : -1; this.commands = List.copyOf(commands); this.processStartTime = processStartTime; - } - - /// Attaches the monitor protocol reader thread so that [stop] can interrupt it. - /// - /// @param readerThread the thread draining the monitor's protocol stream - void attachReaderThread(Thread readerThread) { - this.readerThread = readerThread; + this.specFile = specFile; } @Override @@ -93,11 +98,27 @@ public boolean isRunning() { @Override public void stop() { + // The monitor lives in another JVM, so its exit classification cannot be interrupted the way + // the direct launch path interrupts its ExitWaiter; the rewritten spec file is the signal + // telling the monitor that this exit is a manual cancellation. It is written before the game + // process is destroyed, so the file is guaranteed to be present when the monitor checks it. + Path specFile = this.specFile; + if (specFile != null) + writeCancelSpec(specFile); ProcessHandle handle = this.handle; if (handle != null) handle.destroy(); - Thread readerThread = this.readerThread; - if (readerThread != null) - readerThread.interrupt(); + } + + /// Rewrites the consumed spec file to mark the game process as manually canceled. Best-effort: + /// on a failure the monitor classifies the exit as it does for any other abnormal exit. + private static void writeCancelSpec(Path specFile) { + try { + ProcessSpec spec = new ProcessSpec(); + spec.canceled = true; + JsonUtils.writeToJsonFile(specFile, spec); + } catch (IOException e) { + LOG.warning("Failed to rewrite the monitor spec file " + specFile + " to mark the cancellation", e); + } } } diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java index 60d69b13c7c..312bb51b9fe 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java @@ -79,7 +79,9 @@ public static boolean processArguments(String[] args) { /// Reads the [ProcessSpec] from `specPath`, creates the process it describes, supervises it /// until it exits, and reports the result. /// - /// @param specPath the file containing the [ProcessSpec]; deleted after the process is created + /// @param specPath the file containing the [ProcessSpec]; deleted after the process is created, + /// and possibly rewritten by the main launcher afterward to mark a manual + /// cancellation, which [startMonitors] consumes before reporting the exit /// @throws IOException if the spec file is malformed or the game process cannot be created /// @throws InterruptedException if interrupted while waiting for the game process public static void start(Path specPath) throws IOException, InterruptedException { @@ -120,12 +122,12 @@ public static void start(Path specPath) throws IOException, InterruptedException // process exited early) is not fatal: only the forwarding stops, the log file keeps // collecting the remaining output. try (BufferedWriter logWriter = Files.newBufferedWriter(logFile, MonitorProtocol.CHARSET)) { - startMonitors(gameProcess, logWriter, protocol, spec, processStartTime, logFile); + startMonitors(gameProcess, logWriter, protocol, spec, processStartTime, logFile, specPath); } } static private void startMonitors(ManagedProcess gameProcess, BufferedWriter logWriter, PrintStream protocol, - ProcessSpec spec, long processStartTime, Path logFile) throws InterruptedException { + ProcessSpec spec, long processStartTime, Path logFile, Path specPath) throws InterruptedException { AtomicBoolean parentGone = new AtomicBoolean(false); class LineSink { void accept(String line, boolean isErrorStream) { @@ -158,17 +160,26 @@ void accept(String line, boolean isErrorStream) { Thread exitWaiter = Lang.thread(new ExitWaiter(gameProcess, List.of(stdoutPump, stderrPump), (exitCode, exitType) -> { LOG.info("Game exited with code " + exitCode + "(" + Integer.toHexString(exitCode) + "), type " + exitType); - protocol.println(MonitorProtocol.exitMessage(exitCode, exitType)); + + // The main launcher rewrites the spec file to mark a manual cancellation right before it + // destroys the game process, so a present file can only be caused by an exit requested + // by the user; report it as interrupted, mirroring the interrupted ExitWaiter of the + // direct launch path. + boolean canceled = readCanceled(specPath); + ProcessListener.ExitType reportedType = canceled ? ProcessListener.ExitType.INTERRUPTED : exitType; + if (canceled) + LOG.info("The game process was canceled by the main launcher, reporting the exit as interrupted"); + protocol.println(MonitorProtocol.exitMessage(exitCode, reportedType)); runPostExitCommand(spec); - boolean crashed = exitType != ProcessListener.ExitType.NORMAL; + boolean crashed = reportedType != ProcessListener.ExitType.NORMAL; try { @Nullable Path resultFile = null; if (crashed) { - resultFile = writeResult(spec, gameProcess.getPid(), processStartTime, exitCode, exitType, logFile); + resultFile = writeResult(spec, gameProcess.getPid(), processStartTime, exitCode, reportedType, logFile); } - boolean relaunch = spec.relaunchAlways || (spec.relaunchOnCrash && crashed); + boolean relaunch = !canceled && (spec.relaunchAlways || (spec.relaunchOnCrash && crashed)); if (relaunch && !isParentAlive()) { relaunch(resultFile); } @@ -201,6 +212,27 @@ else if (spec.directory != null && !spec.directory.isEmpty()) } } + /// Returns whether the main launcher rewrote the spec file to mark the game process as manually + /// canceled, consuming the rewritten file so that it does not outlive this check. + private static boolean readCanceled(Path specPath) { + if (!Files.exists(specPath)) + return false; + boolean canceled = false; + try { + ProcessSpec spec = JsonUtils.fromJsonFile(specPath, ProcessSpec.class); + canceled = spec != null && spec.canceled; + } catch (IOException e) { + LOG.warning("Failed to read the rewritten monitor spec file " + specPath, e); + } finally { + try { + Files.deleteIfExists(specPath); + } catch (IOException e) { + LOG.warning("Failed to delete the rewritten monitor spec file " + specPath, e); + } + } + return canceled; + } + /// Writes the [ResultSpec] consumed by a relaunched launcher to a temp file. private static Path writeResult(ProcessSpec spec, long pid, long processStartTime, int exitCode, ProcessListener.ExitType exitType, Path logFile) throws IOException { diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java index 7ae71e3789a..4226d485290 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java @@ -50,6 +50,11 @@ final class ProcessSpec { boolean inheritStdin; /// The post-exit command to run after the game exits, already tokenized, or `null`. @Nullable List postExitCommand; + /// Whether the main launcher manually canceled the game process, written by the main launcher + /// over the already consumed spec file right before it destroys the game process. The monitor + /// checks this flag before reporting the exit, so a manual cancellation is not mistaken for a + /// crash; it is always `false` in the spec initially handed to the monitor. + boolean canceled; /// Whether to relaunch the launcher after the game exits unconditionally. boolean relaunchAlways; /// Whether to relaunch the launcher after the game exits abnormally. From d23d263813b1a5939a2faf3033c3851d36b542c7 Mon Sep 17 00:00:00 2001 From: fishdry Date: Thu, 1 Oct 2026 21:56:09 +0800 Subject: [PATCH 07/44] =?UTF-8?q?style:=20=E4=BC=98=E5=8C=96=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java | 1 + .../src/main/java/org/jackhuang/hmcl/monitor/ResultSpec.java | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java index 312bb51b9fe..011cb82198a 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java @@ -148,6 +148,7 @@ void accept(String line, boolean isErrorStream) { } } } + LineSink sink = new LineSink(); Charset encoding = Charset.forName(Objects.requireNonNullElse(spec.encoding, Charset.defaultCharset().name())); diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ResultSpec.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ResultSpec.java index 73f4191afaf..cacfebbed01 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ResultSpec.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ResultSpec.java @@ -17,7 +17,6 @@ */ package org.jackhuang.hmcl.monitor; -import org.jackhuang.hmcl.launch.ProcessListener; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; From 65241165c77c39c2dfd7fdac60a0f654d785d29e Mon Sep 17 00:00:00 2001 From: fishdry Date: Thu, 1 Oct 2026 22:21:37 +0800 Subject: [PATCH 08/44] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=87=A0?= =?UTF-8?q?=E5=A4=84=20corner=20case?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/monitor/MonitorCrashReporter.java | 2 +- .../jackhuang/hmcl/monitor/MonitorClient.java | 12 ++++++++-- .../hmcl/monitor/MonitorSupervisor.java | 23 ++++++++++++++----- 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java index fe69c1042b0..b075698c695 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java @@ -158,7 +158,7 @@ private static void presentCrashWindow(ResultSpec result, HMCLGameInstance insta LaunchOptions launchOptions = rebuildLaunchOptions(result); List logs = readSessionLogs(result.logFile); if (result.logFile != null) { - Files.delete(Path.of(result.logFile)); + Files.deleteIfExists(Path.of(result.logFile)); } new GameCrashWindow(process, exitType, instance, launchOptions, logs).show(); diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java index 64008e1f884..5e3a5340577 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java @@ -210,10 +210,12 @@ private static String[] readHandshake(Process daemon, BufferedReader reader) thr } }, "hmcl-monitor-handshake", true); + boolean handshakeCompleted = false; try { - return future.get(MonitorProtocol.HANDSHAKE_TIMEOUT, TimeUnit.MILLISECONDS); + String[] handshake = future.get(MonitorProtocol.HANDSHAKE_TIMEOUT, TimeUnit.MILLISECONDS); + handshakeCompleted = true; + return handshake; } catch (TimeoutException e) { - daemon.destroy(); throw new IOException("Timed out waiting for the HMCL monitor handshake", e); } catch (InterruptedException e) { Thread.currentThread().interrupt(); @@ -223,6 +225,12 @@ private static String[] readHandshake(Process daemon, BufferedReader reader) thr throw new IOException(cause.getMessage(), cause); } finally { waiter.interrupt(); + // The monitor must not outlive a failed handshake: it would create and supervise the + // game process on its own, leaving behind an orphaned game, e.g. when the user cancels + // the launch while the handshake is still pending. Destroying an already-dead monitor + // (the monitor exited before the handshake) is a no-op. + if (!handshakeCompleted) + daemon.destroy(); } } diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java index 011cb82198a..b759c24fc6c 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java @@ -47,8 +47,9 @@ ///

The monitor has no user interface. It owns the game process, so it always knows the real exit /// code even after the main launcher process has exited. Game output is teed into a session log /// file and forwarded to the main launcher process over [the protocol][MonitorProtocol]; after the -/// game exits, the result is written to a result file, the post-exit command is run, and HMCL is -/// relaunched if the launch options ask for it and the main launcher process is already gone. +/// game exits, the post-exit command is run, and HMCL is relaunched if the launch options ask for +/// it and the main launcher process is already gone. The crash result file and the session log are +/// only kept when a relaunched launcher is going to consume them. @NotNullByDefault public final class MonitorSupervisor { @@ -176,14 +177,24 @@ void accept(String line, boolean isErrorStream) { boolean crashed = reportedType != ProcessListener.ExitType.NORMAL; try { - @Nullable Path resultFile = null; - if (crashed) { - resultFile = writeResult(spec, gameProcess.getPid(), processStartTime, exitCode, reportedType, logFile); - } boolean relaunch = !canceled && (spec.relaunchAlways || (spec.relaunchOnCrash && crashed)); + + @Nullable Path resultFile = null; if (relaunch && !isParentAlive()) { + if (crashed) + resultFile = writeResult(spec, gameProcess.getPid(), processStartTime, exitCode, reportedType, logFile); relaunch(resultFile); } + + // The session log is only consumed through a crash result file; deleting it in + // every other case keeps it from accumulating in the temporary directory. + if (resultFile == null) { + try { + Files.deleteIfExists(logFile); + } catch (IOException e) { + LOG.warning("Failed to delete the session log file " + logFile, e); + } + } } catch (IOException e) { LOG.error("Failed to write the monitor result file or relaunch the launcher", e); } From 6f441d673afed0f6fbc17a530861bdab7ed22a17 Mon Sep 17 00:00:00 2001 From: fishdry Date: Thu, 1 Oct 2026 22:59:29 +0800 Subject: [PATCH 09/44] =?UTF-8?q?docs:=20=E4=BF=AE=E7=BC=AE=E6=B3=A8?= =?UTF-8?q?=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jackhuang/hmcl/game/LauncherHelper.java | 7 +++---- .../hmcl/monitor/MonitorCrashReporter.java | 5 ++--- .../org/jackhuang/hmcl/launch/Launcher.java | 3 +-- .../hmcl/util/platform/ProcessInfo.java | 7 ++----- .../jackhuang/hmcl/monitor/MonitorClient.java | 19 +++++++----------- .../hmcl/monitor/MonitorGameProcess.java | 20 ++++++------------- .../hmcl/monitor/MonitorSupervisor.java | 11 ++++------ .../jackhuang/hmcl/monitor/ProcessSpec.java | 6 ++---- 8 files changed, 27 insertions(+), 51 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java index a192fb48f63..a1ea4a45330 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -942,10 +942,9 @@ public void run() { /// Called when the game window has been detected and the launch has succeeded. /// - ///

When the game process is managed by the HMCL monitor, the main launcher process may exit - /// right away for the [LauncherVisibility#HIDE] and [LauncherVisibility#HIDE_AND_REOPEN] - /// visibilities: the monitor supervises the game process, analyzes its exit, and relaunches - /// HMCL if configured to, so lingering here would only waste resources. + ///

When the game exit is handled by the HMCL monitor, the application exits right away for + /// the [LauncherVisibility#HIDE] and [LauncherVisibility#HIDE_AND_REOPEN] visibilities; + /// otherwise this method delegates to [finishLaunch]. private void finishLaunchAfterWindowDetected() { if (MonitorClient.isEnabled() && !showLogs && (launcherVisibility == LauncherVisibility.HIDE diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java index b075698c695..634a688d933 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java @@ -71,9 +71,8 @@ private MonitorCrashReporter() { /// Shows the game crash window for the given monitor result file. /// ///

Must be called on the JavaFX application thread after the main window has been set up. - /// The result file is deleted once it has been consumed. When the launched instance cannot be - /// resolved even after every game repository has finished loading, the crash window is skipped; - /// the session log file remains on disk for inspection. + /// The result file is deleted once consumed. The crash window is skipped when the launched + /// instance cannot be resolved; the session log file remains on disk for inspection. /// /// @param resultFile the result file written by the monitor public static void show(Path resultFile) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/Launcher.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/Launcher.java index 1f0af7fce74..4eae1ec39d7 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/Launcher.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/Launcher.java @@ -84,8 +84,7 @@ public GameInstance getInstance() { /// Starts the game process. /// - /// @return the information of the launched game process; a [org.jackhuang.hmcl.util.platform.ManagedProcess] when the process is - /// spawned directly, or a view of the monitor-supervised process otherwise + /// @return the information of the launched game process /// @throws IOException if the process cannot be created or launch preparation fails /// @throws InterruptedException if interrupted while preparing or starting the process public abstract ProcessInfo launch() throws IOException, InterruptedException; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ProcessInfo.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ProcessInfo.java index 86f0b8f9329..9fe8fd1edb8 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ProcessInfo.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ProcessInfo.java @@ -47,10 +47,7 @@ public interface ProcessInfo { /// Returns whether the game process is still running. boolean isRunning(); - /// Destroys the game process. - /// - ///

The exit of a manually stopped game must be recognizable as a cancellation by whoever - /// classifies the exit, so that a cancelled launch is not mistaken for a crashed game; the - /// mechanism depends on how the implementation supervises the process. + /// Destroys the game process, in a way that lets the exit classifier recognize the exit as a + /// manual cancellation rather than a crash. void stop(); } diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java index 5e3a5340577..e8699c1d172 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java @@ -66,11 +66,9 @@ public static boolean isEnabled() { /// Launches the game process via a new HMCL monitor process and returns a view of it. /// - ///

This method blocks until the monitor has created the game process and reported its id - /// back. A reader thread is spawned afterward to dispatch the monitor's protocol messages: - /// game output lines are forwarded to the [listener][MonitorLaunchContext#listener()], and the - /// exit event triggers [ProcessListener#onExit]. The reader thread keeps draining the stream - /// even when the listener is absent, since an undrained pipe would eventually block the monitor. + ///

Blocks until the monitor has created the game process and reported its id back; game + /// output and the exit event are dispatched to the [listener][MonitorLaunchContext#listener()] + /// asynchronously afterward. /// /// @param context the launch context prepared by [org.jackhuang.hmcl.launch.DefaultLauncher] /// @return a view of the game process managed by the monitor @@ -107,7 +105,9 @@ public static MonitorGameProcess launchNewMonitor(MonitorLaunchContext context) listener.setProcess(process); // The reader thread keeps draining the protocol stream until the monitor exits, so that the - // exit message of a canceled launch is still dispatched and classified as interrupted. + // exit message of a canceled launch is still dispatched and classified as interrupted. It + // must drain even without a listener, since an undrained pipe would eventually block the + // monitor. Lang.thread(() -> { try { String line; @@ -131,12 +131,8 @@ private static ProcessSpec buildSpec(MonitorLaunchContext context, LaunchOptions spec.directory = builder.directory().getAbsolutePath(); spec.environment = new LinkedHashMap<>(builder.environment()); spec.encoding = context.encoding().name(); - // The direct launch path lets a listener-less game inherit the launcher's standard input; - // keep that behavior for the CLOSE-like launcher visibility. spec.inheritStdin = context.listener() == null; spec.postExitCommand = context.postExitCommand(); - // The relaunch policy comes from the launch context; the monitor re-checks the liveness of - // this process before relaunching, so a still-alive launcher is never duplicated. MonitorLaunchContext.RelaunchPolicy relaunchPolicy = context.relaunchPolicy(); spec.relaunchAlways = relaunchPolicy == MonitorLaunchContext.RelaunchPolicy.ALWAYS; spec.relaunchOnCrash = relaunchPolicy == MonitorLaunchContext.RelaunchPolicy.ON_CRASH; @@ -227,8 +223,7 @@ private static String[] readHandshake(Process daemon, BufferedReader reader) thr waiter.interrupt(); // The monitor must not outlive a failed handshake: it would create and supervise the // game process on its own, leaving behind an orphaned game, e.g. when the user cancels - // the launch while the handshake is still pending. Destroying an already-dead monitor - // (the monitor exited before the handshake) is a no-op. + // the launch while the handshake is still pending. if (!handshakeCompleted) daemon.destroy(); } diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java index 48936dc6ac6..e135f4496eb 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java @@ -31,14 +31,9 @@ /// A view of the game process supervised by the HMCL monitor. /// ///

Unlike [org.jackhuang.hmcl.util.platform.ManagedProcess], the game process is not spawned nor -/// owned by this JVM; only its id, command line and start time are known, learned from the monitor's -/// handshake. [stop] rewrites the spec file to mark the game process as canceled before destroying -/// it through its [ProcessHandle], so the monitor reports the exit as interrupted rather than a -/// crash. -/// -///

Instances also represent already-exited games when presenting the crash window of a relaunched -/// launcher; their [handle][#getHandle] may be unresolvable and their spec file is unknown in that -/// case. +/// owned by this JVM; only the information reported by the monitor's handshake is known. Instances +/// may also represent already-exited games when presenting the crash window of a relaunched +/// launcher, in which case the [handle][#getHandle] may be unresolvable and the spec file unknown. @NotNullByDefault public final class MonitorGameProcess implements ProcessInfo { @@ -46,8 +41,7 @@ public final class MonitorGameProcess implements ProcessInfo { private final long pid; private final List commands; private final long processStartTime; - /// The file the spec was exchanged with the monitor through, rewritten by [stop] to mark the - /// cancellation, or `null` when the game process is a dead one presented by a relaunched launcher. + /// The file the spec was exchanged with the monitor through, or `null` when unknown. private final @Nullable Path specFile; /// Creates a view of the monitor-supervised game process. @@ -98,10 +92,8 @@ public boolean isRunning() { @Override public void stop() { - // The monitor lives in another JVM, so its exit classification cannot be interrupted the way - // the direct launch path interrupts its ExitWaiter; the rewritten spec file is the signal - // telling the monitor that this exit is a manual cancellation. It is written before the game - // process is destroyed, so the file is guaranteed to be present when the monitor checks it. + // The monitor cannot be interrupted the way the direct launch path's ExitWaiter is; the + // rewritten spec file is the cancellation signal, so write it before destroying the process. Path specFile = this.specFile; if (specFile != null) writeCancelSpec(specFile); diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java index b759c24fc6c..1a09b9b1576 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java @@ -80,9 +80,7 @@ public static boolean processArguments(String[] args) { /// Reads the [ProcessSpec] from `specPath`, creates the process it describes, supervises it /// until it exits, and reports the result. /// - /// @param specPath the file containing the [ProcessSpec]; deleted after the process is created, - /// and possibly rewritten by the main launcher afterward to mark a manual - /// cancellation, which [startMonitors] consumes before reporting the exit + /// @param specPath the file containing the [ProcessSpec] /// @throws IOException if the spec file is malformed or the game process cannot be created /// @throws InterruptedException if interrupted while waiting for the game process public static void start(Path specPath) throws IOException, InterruptedException { @@ -163,10 +161,9 @@ void accept(String line, boolean isErrorStream) { Thread exitWaiter = Lang.thread(new ExitWaiter(gameProcess, List.of(stdoutPump, stderrPump), (exitCode, exitType) -> { LOG.info("Game exited with code " + exitCode + "(" + Integer.toHexString(exitCode) + "), type " + exitType); - // The main launcher rewrites the spec file to mark a manual cancellation right before it - // destroys the game process, so a present file can only be caused by an exit requested - // by the user; report it as interrupted, mirroring the interrupted ExitWaiter of the - // direct launch path. + // A present spec file can only mean the main launcher marked a manual cancellation + // right before destroying the game process (see MonitorGameProcess#stop); report the + // exit as interrupted like the direct launch path does. boolean canceled = readCanceled(specPath); ProcessListener.ExitType reportedType = canceled ? ProcessListener.ExitType.INTERRUPTED : exitType; if (canceled) diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java index 4226d485290..99b39d1e5ed 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java @@ -50,10 +50,8 @@ final class ProcessSpec { boolean inheritStdin; /// The post-exit command to run after the game exits, already tokenized, or `null`. @Nullable List postExitCommand; - /// Whether the main launcher manually canceled the game process, written by the main launcher - /// over the already consumed spec file right before it destroys the game process. The monitor - /// checks this flag before reporting the exit, so a manual cancellation is not mistaken for a - /// crash; it is always `false` in the spec initially handed to the monitor. + /// Whether the main launcher manually canceled the game process; the monitor reports such an + /// exit as interrupted. boolean canceled; /// Whether to relaunch the launcher after the game exits unconditionally. boolean relaunchAlways; From d354dcba5d0145a40f32603813254cd02446414e Mon Sep 17 00:00:00 2001 From: fishdry Date: Sun, 4 Oct 2026 12:56:02 +0800 Subject: [PATCH 10/44] =?UTF-8?q?fix:=20=E4=BD=BF=20GameRepository=20?= =?UTF-8?q?=E7=9A=84=E5=8A=A0=E8=BD=BD=E6=A0=87=E5=BF=97=E4=B8=8E=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E5=8F=91=E5=B8=83=E5=8E=9F=E5=AD=90=E5=8F=AF=E8=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/game/DefaultGameRepository.java | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java index 6725d4e318e..3b4127c5ea7 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -128,10 +128,9 @@ protected void flushPendingInstanceWrites() throws IOException { /// @throws IllegalStateException if a draft is active public void setBaseDirectory(Path baseDirectory) { checkNoActiveDraft("set base directory"); - // Mark unloaded before publishing so snapshot listeners do not treat the empty snapshot as ready. - this.loaded = false; DefaultGameRepositorySnapshot initial = createSnapshot(createLayout(baseDirectory)); - publishSnapshot(initial); + // The empty snapshot is published as not loaded so snapshot listeners do not treat it as ready. + publishSnapshot(initial, false); } /// {@inheritDoc} @@ -171,6 +170,23 @@ protected void publishSnapshot(DefaultGameRepositorySnapshot newSnapshot) { }); } + /// Publishes `newSnapshot` together with the repository's loaded state as one atomic step. + /// + /// The loaded flag is written immediately before the snapshot property is updated on the + /// JavaFX application thread, so snapshot listeners observe a ready repository when the + /// published state is loaded, and JavaFX-thread observers that see the new loaded state also + /// observe the new snapshot instead of the previous one. + /// + /// @param newSnapshot the snapshot to publish + /// @param loaded the loaded state to publish together with the snapshot + protected void publishSnapshot(DefaultGameRepositorySnapshot newSnapshot, boolean loaded) { + newSnapshot.seal(); + runOnFxThreadAndWait(() -> { + this.loaded = loaded; + snapshot.set(newSnapshot); + }); + } + /// Publishes the immutable successor snapshot of the repository's active draft. /// /// @param draft the active draft @@ -299,9 +315,9 @@ private void refreshRepository() { } } - // Mark loaded before publishing so snapshot listeners observe a ready repository. - loaded = true; - publishSnapshot(newSnapshot); + // Publish the scanned snapshot as loaded; the flag must become visible together with the + // snapshot so FX-thread consumers never act on the loaded flag with a stale snapshot. + publishSnapshot(newSnapshot, true); } /// Loads one instance directory without renaming on-disk JSON or jar files. From 36f8bfb76436297722b2cbaad473d0e7ae6dfd41 Mon Sep 17 00:00:00 2001 From: fishdry Date: Sun, 4 Oct 2026 13:10:13 +0800 Subject: [PATCH 11/44] =?UTF-8?q?fix:=20=E5=B4=A9=E6=BA=83=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E6=8C=89=E6=B8=B8=E6=88=8F=E7=9B=AE=E5=BD=95=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96=20ID=20=E5=AE=9A=E4=BD=8D=E5=AE=9E=E4=BE=8B?= =?UTF-8?q?=EF=BC=8C=E6=9B=BF=E4=BB=A3=E5=85=A8=E7=9B=AE=E5=BD=95=E6=89=AB?= =?UTF-8?q?=E6=8F=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jackhuang/hmcl/game/LauncherHelper.java | 1 + .../hmcl/monitor/MonitorCrashReporter.java | 148 ++++++++++-------- .../hmcl/setting/GameDirectoryManager.java | 36 +++-- .../hmcl/launch/DefaultLauncher.java | 16 +- .../jackhuang/hmcl/monitor/MonitorClient.java | 1 + .../hmcl/monitor/MonitorSupervisor.java | 1 + .../jackhuang/hmcl/monitor/ProcessSpec.java | 3 + .../jackhuang/hmcl/monitor/ResultSpec.java | 3 + 8 files changed, 129 insertions(+), 80 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java index a1ea4a45330..29cfedcb4e3 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -305,6 +305,7 @@ private void launch0() { case HIDE -> DefaultLauncher.MonitorLaunchContext.RelaunchPolicy.ON_CRASH; default -> DefaultLauncher.MonitorLaunchContext.RelaunchPolicy.NEVER; }); + launcher.setGameDirectoryId(gameInstance.getRepository().getGameDirectory().getId().toString()); return launcher; }).thenComposeAsync(launcher -> { // launcher is prev task's result diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java index 634a688d933..e77d6bb4e9d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java @@ -28,6 +28,7 @@ import org.jackhuang.hmcl.java.JavaInfo; import org.jackhuang.hmcl.java.JavaRuntime; import org.jackhuang.hmcl.launch.ProcessListener; +import org.jackhuang.hmcl.setting.GameDirectoryID; import org.jackhuang.hmcl.setting.GameDirectoryManager; import org.jackhuang.hmcl.ui.GameCrashWindow; import org.jackhuang.hmcl.util.Log4jLevel; @@ -42,8 +43,6 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; import static javafx.application.Platform.runLater; @@ -60,8 +59,8 @@ private MonitorCrashReporter() { /// Handles the `--crash-report` argument of a launcher process relaunched by the monitor. /// /// @param args the process arguments - /// @return the path of the monitor result file to present, or `null` if the arguments do not - /// request a crash report + /// @return the path of the monitor result file to present, or `null` when the arguments do not + /// request a crash report public static @Nullable Path processArguments(String[] args) { if (args.length == 2 && args[0].equals("--crash-report")) return Path.of(args[1]); @@ -71,8 +70,10 @@ private MonitorCrashReporter() { /// Shows the game crash window for the given monitor result file. /// ///

Must be called on the JavaFX application thread after the main window has been set up. - /// The result file is deleted once consumed. The crash window is skipped when the launched - /// instance cannot be resolved; the session log file remains on disk for inspection. + /// The result file is deleted once consumed. When the launched instance cannot be resolved + /// immediately and its repository is still loading, the resolution is deferred until the + /// repository publishes its loaded snapshot. The crash window is skipped when the instance + /// still cannot be resolved; the session log file remains on disk for inspection. /// /// @param resultFile the result file written by the monitor public static void show(Path resultFile) { @@ -91,51 +92,78 @@ public static void show(Path resultFile) { if (result == null) return; - List repositories = GameDirectoryManager.getOrCreateAllRepositories(); - HMCLGameInstance instance = findInstance(repositories, result); + HMCLGameRepository repository = resolveRepository(result.gameDirectoryId); + HMCLGameInstance instance = findInstance(repository, result.instanceId); if (instance != null) { presentCrashWindow(result, instance); return; } - // The relaunched launcher shows its main window before the game repositories have finished - // loading their snapshots asynchronously, so the instance may not be resolvable yet; wait - // for the pending loads and try again when each repository publishes its snapshot. - List pending = repositories.stream() - .filter(repository -> !repository.isLoaded()) - .toList(); - if (pending.isEmpty()) { - LOG.warning("Cannot resolve the launched instance " + result.instanceId + " (" - + describeRepositories(repositories) + "), skip showing the crash window." - + " The session log is at " + result.logFile); + // The relaunched launcher shows its main window before the game repository has finished + // loading its snapshot asynchronously, so the launched instance may exist but not be + // resolvable yet. An already-loaded repository will never gain the instance and a missing + // repository has nothing to wait for, so only an unloaded repository is waited on. + if (repository == null || repository.isLoaded()) { + giveUp(result.instanceId, result.logFile, repository); return; } - LOG.info("Waiting for " + pending.size() + " game repositories to load before showing the crash window"); - AtomicInteger pendingCount = new AtomicInteger(pending.size()); - AtomicBoolean handled = new AtomicBoolean(false); - for (HMCLGameRepository repository : pending) { - ChangeListener listener = new ChangeListener<>() { - @Override - public void changed(ObservableValue observable, - DefaultGameRepositorySnapshot oldValue, DefaultGameRepositorySnapshot newValue) { - repository.snapshotProperty().removeListener(this); - if (handled.get()) - return; - - HMCLGameInstance resolved = findInstance(repositories, result); - if (resolved != null) { - if (handled.compareAndSet(false, true)) - runLater(() -> presentCrashWindow(result, resolved)); - } else if (pendingCount.decrementAndGet() == 0 && handled.compareAndSet(false, true)) { - LOG.warning("Cannot resolve the launched instance " + result.instanceId + " (" - + describeRepositories(repositories) + "), skip showing the crash window." - + " The session log is at " + result.logFile); - } - } - }; - repository.snapshotProperty().addListener(listener); + LOG.info("Waiting for the game repository to load before showing the crash window"); + // Snapshot publication runs on the JavaFX application thread and this listener removes + // itself before scheduling the re-resolution, so the re-resolution is scheduled exactly + // once. + ChangeListener listener = new ChangeListener<>() { + @Override + public void changed(ObservableValue observable, + DefaultGameRepositorySnapshot oldValue, DefaultGameRepositorySnapshot newValue) { + repository.snapshotProperty().removeListener(this); + runLater(() -> resolveAndPresent(result, repository)); + } + }; + repository.snapshotProperty().addListener(listener); + } + + /// Returns the repository of the registered game directory with the given id, starting its + /// initial refresh when needed. + /// + /// @param gameDirectoryId the persistent game directory id recorded by the monitor, or `null` + /// when unknown + /// @return the matching repository, or `null` when no registered game directory has this id, + /// e.g. because the user removed the game directory while the game was running + private static @Nullable HMCLGameRepository resolveRepository(@Nullable String gameDirectoryId) { + if (gameDirectoryId == null) + return null; + GameDirectoryID id; + try { + id = GameDirectoryID.parse(gameDirectoryId); + } catch (IllegalArgumentException e) { + LOG.warning("Malformed game directory id in the monitor result file: " + gameDirectoryId); + return null; } + return GameDirectoryManager.getOrCreateRepositoryByDirectoryId(id); + } + + /// Resolves the launched instance after the repository has loaded, and shows the crash window + /// when it is found. + private static void resolveAndPresent(ResultSpec result, HMCLGameRepository repository) { + HMCLGameInstance instance = findInstance(repository, result.instanceId); + if (instance != null) + presentCrashWindow(result, instance); + else + giveUp(result.instanceId, result.logFile, repository); + } + + /// Logs that the launched instance could not be resolved and the crash window is skipped. + private static void giveUp(@Nullable String instanceId, @Nullable String logFile, + @Nullable HMCLGameRepository repository) { + LOG.warning("Cannot resolve the launched instance " + instanceId + " (" + + (repository != null + ? repository.getSnapshot().getInstanceCount() + " instances, loaded=" + + repository.isLoaded() + ", base directory " + + repository.getLayout().getBaseDirectory() + : "no registered game directory has the recorded id") + + "), skip showing the crash window." + + " The session log is at " + logFile); } /// Builds and shows the crash window from the monitor result and the resolved instance. @@ -166,14 +194,6 @@ private static void presentCrashWindow(ResultSpec result, HMCLGameInstance insta } } - /// Describes the given repositories with their loaded instance counts, for diagnostics. - private static String describeRepositories(List repositories) { - return repositories.stream() - .map(repository -> repository.getSnapshot().getInstanceCount() + " instances, loaded=" - + repository.isLoaded()) - .collect(Collectors.joining("; ", "[", "]")); - } - /// Rebuilds a [LaunchOptions] carrying the display-relevant fields recorded by the monitor. /// /// The [JavaRuntime] is guaranteed to be present, since the crash window dereferences it for @@ -211,8 +231,8 @@ private static LaunchOptions rebuildLaunchOptions(ResultSpec result) { return builder.create(); } - /// Reads the session log file written by the monitor into [Log] entries, guessing the level of - /// each line. Returns an empty list when the log file is missing or unreadable. + /// Reads the session log file written by the monitor into [Log] entries, guessing the level + /// of each line. Returns an empty list when the log file is missing or unreadable. private static List readSessionLogs(@Nullable String logFile) { if (logFile == null) return List.of(); @@ -226,24 +246,24 @@ private static List readSessionLogs(@Nullable String logFile) { } } - /// Resolves the launched instance by its id across the given game directories. - private static @Nullable HMCLGameInstance findInstance(List repositories, ResultSpec result) { - if (result.instanceId == null) + /// Resolves the launched instance by its id in the given repository. + /// + /// @param repository the repository of the recorded game directory, or `null` when no + /// registered game directory matches + /// @param instanceId the instance id recorded by the monitor, or `null` when unknown + /// @return the instance, or `null` when it cannot be resolved + private static @Nullable HMCLGameInstance findInstance(@Nullable HMCLGameRepository repository, + @Nullable String instanceId) { + if (repository == null || instanceId == null) return null; GameInstanceID id; try { - id = new GameInstanceID(result.instanceId); + id = new GameInstanceID(instanceId); } catch (IllegalArgumentException e) { - LOG.warning("Malformed instance id in the monitor result file: " + result.instanceId); + LOG.warning("Malformed instance id in the monitor result file: " + instanceId); return null; } - - for (HMCLGameRepository repository : repositories) { - HMCLGameInstance instance = repository.findInstance(id); - if (instance != null) - return instance; - } - return null; + return repository.findInstance(id); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectoryManager.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectoryManager.java index dba01254247..2e117199f5d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectoryManager.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectoryManager.java @@ -30,7 +30,6 @@ import org.jackhuang.hmcl.util.i18n.LocalizedText; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; -import org.jetbrains.annotations.Unmodifiable; import org.jetbrains.annotations.UnknownNullability; import org.jetbrains.annotations.UnmodifiableView; @@ -279,21 +278,28 @@ private static HMCLGameRepository getOrCreateRepository(GameDirectory gameDirect return repositories.computeIfAbsent(gameDirectory, HMCLGameRepository::new); } - /// Creates repositories for every merged game directory and returns them, starting the initial - /// refresh of the ones that have not loaded yet. + /// Returns the repository of the registered game directory with the given id, creating it when + /// needed, or `null` when no registered game directory has this id. /// - ///

Used for lookups that are not tied to the selected game directory, e.g. resolving an - /// instance id recorded by the HMCL monitor. The selected repository's refresh is managed by the - /// selection flow and is not started again here. - public static @Unmodifiable List getOrCreateAllRepositories() { - List result = new ArrayList<>(); - for (GameDirectory directory : mergedGameDirectoriesUnmodifiable) { - HMCLGameRepository repository = getOrCreateRepository(directory); - if (repository != selectedRepository.get() && !repository.isLoaded()) - repository.refreshAsync().start(); - result.add(repository); - } - return result; + ///

Used for lookups that only know the persistent directory id, e.g. resolving an instance + /// id recorded by the HMCL monitor. The returned repository is refreshed when it is not loaded; + /// the selected repository's refresh is managed by the selection flow and is not started again + /// here. + /// + /// @param id the persistent id of the game directory to find + /// @return the matching repository, or `null` when no registered game directory has this id + public static @Nullable HMCLGameRepository getOrCreateRepositoryByDirectoryId(GameDirectoryID id) { + GameDirectory match = mergedGameDirectoriesUnmodifiable.stream() + .filter(directory -> directory.getId().equals(id)) + .findFirst() + .orElse(null); + if (match == null) + return null; + + HMCLGameRepository repository = getOrCreateRepository(match); + if (repository != selectedRepository.get() && !repository.isLoaded()) + repository.refreshAsync().start(); + return repository; } /// Adds a game directory to the per-workspace store. diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java index 4be073f055b..1cd52e026e9 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java @@ -69,6 +69,9 @@ public interface LaunchMonitorFunction { /// or `null` if game output is not needed /// @param encoding the charset of the game process output /// @param options the launch options, providing instance identity for the result report + /// @param gameDirectoryId the persistent id of the registered game directory owning the + /// launched instance, recorded for the result report, or `null` when + /// unknown /// @param postExitCommand the post-exit command to run after the game exits, already tokenized /// and with placeholders expanded, or `null` if not configured /// @param relaunchPolicy what the monitor should do after the game process exits @@ -77,6 +80,7 @@ public record MonitorLaunchContext( @Nullable ProcessListener listener, Charset encoding, LaunchOptions options, + @Nullable String gameDirectoryId, @Nullable List postExitCommand, RelaunchPolicy relaunchPolicy) { @@ -97,6 +101,9 @@ public enum RelaunchPolicy { private @Nullable LaunchMonitorFunction launchMonitor = null; /// The relaunch policy applied by the HMCL monitor process after the game process exits. private MonitorLaunchContext.RelaunchPolicy relaunchPolicy = MonitorLaunchContext.RelaunchPolicy.NEVER; + /// The persistent id of the registered game directory owning the launched instance, recorded + /// for the monitor result report. + private @Nullable String gameDirectoryId; public DefaultLauncher(GameInstance instance, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) { super(instance, manifest, authInfo, options, listener, daemon); @@ -718,6 +725,12 @@ public void setRelaunchPolicy(MonitorLaunchContext.RelaunchPolicy relaunchPolicy this.relaunchPolicy = relaunchPolicy; } + /// Sets the persistent id of the registered game directory owning the launched instance, + /// recorded in the monitor result. Ignored unless [a monitor launcher is set][#setLaunchMonitor]. + public void setGameDirectoryId(@Nullable String gameDirectoryId) { + this.gameDirectoryId = gameDirectoryId; + } + @Override public ProcessInfo launch() throws IOException, InterruptedException { Path nativeFolder = getNativeFolder(); @@ -769,7 +782,8 @@ public ProcessInfo launch() throws IOException, InterruptedException { if (StringUtils.isNotBlank(options.getPostExitCommand())) postExitCommand = StringUtils.tokenize(options.getPostExitCommand(), getEnvVars(nativeFolder)); - return launchMonitor.apply(new MonitorLaunchContext(builder, listener, command.encoding, options, postExitCommand, relaunchPolicy)); + return launchMonitor.apply(new MonitorLaunchContext(builder, listener, command.encoding, options, + gameDirectoryId, postExitCommand, relaunchPolicy)); } process = builder.start(); diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java index e8699c1d172..cb29b146841 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java @@ -138,6 +138,7 @@ private static ProcessSpec buildSpec(MonitorLaunchContext context, LaunchOptions spec.relaunchOnCrash = relaunchPolicy == MonitorLaunchContext.RelaunchPolicy.ON_CRASH; if (options.getInstanceId() != null) spec.instanceId = options.getInstanceId().toString(); + spec.gameDirectoryId = context.gameDirectoryId(); spec.versionName = options.getVersionName(); if (options.getGameDir() != null) spec.gameDir = options.getGameDir().toString(); diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java index 1a09b9b1576..7db00269cb6 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java @@ -253,6 +253,7 @@ private static Path writeResult(ProcessSpec spec, long pid, long processStartTim result.exitType = exitType.name(); result.logFile = logFile.toString(); result.instanceId = spec.instanceId; + result.gameDirectoryId = spec.gameDirectoryId; result.versionName = spec.versionName; result.gameDir = spec.gameDir; result.maxMemory = spec.maxMemory; diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java index 99b39d1e5ed..8c03c484588 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java @@ -59,6 +59,9 @@ final class ProcessSpec { boolean relaunchOnCrash; /// The id of the launched instance, or `null` when unknown. @Nullable String instanceId; + /// The persistent id of the registered game directory owning the launched instance, or `null` + /// when unknown. + @Nullable String gameDirectoryId; /// The display name of the launched version, or `null` when unknown. @Nullable String versionName; /// The game directory of the launched instance, or `null` when unknown. diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ResultSpec.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ResultSpec.java index cacfebbed01..8eca6d5fb04 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ResultSpec.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ResultSpec.java @@ -38,6 +38,9 @@ public final class ResultSpec { @Nullable String logFile; /// The id of the launched instance, or `null` when unknown. @Nullable String instanceId; + /// The persistent id of the registered game directory owning the launched instance, or `null` + /// when unknown. + @Nullable String gameDirectoryId; /// The display name of the launched version, or `null` when unknown. @Nullable String versionName; /// The game directory of the launched instance, or `null` when unknown. From 42eb85cb1fa4f5cfdc8ca41c0eb53fdad17ebc78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B1=BC=E5=89=8D=E4=B8=89=E7=99=BE?= Date: Sun, 4 Oct 2026 21:31:06 +0800 Subject: [PATCH 12/44] =?UTF-8?q?fix:=20=E9=99=90=E5=88=B6=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E8=AF=BB=E5=8F=96=E9=95=BF=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/monitor/MonitorCrashReporter.java | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java index e77d6bb4e9d..cdd73555b7d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java @@ -231,16 +231,25 @@ private static LaunchOptions rebuildLaunchOptions(ResultSpec result) { return builder.create(); } - /// Reads the session log file written by the monitor into [Log] entries, guessing the level - /// of each line. Returns an empty list when the log file is missing or unreadable. - private static List readSessionLogs(@Nullable String logFile) { + /// Reads the tail of the session log file written by the monitor into [Log] entries, guessing + /// the level of each line. At most [Log#getLogLines] lines are retained, matching the + /// in-memory log limit of the direct launch path. Returns an empty list when the log file is + /// missing or unreadable. + private static @Unmodifiable List readSessionLogs(@Nullable String logFile) { if (logFile == null) return List.of(); - try { - return Files.readAllLines(Path.of(logFile), MonitorProtocol.CHARSET).stream() - .map(line -> new Log(line, Log4jLevel.guessLevel(line))) - .collect(Collectors.toList()); - } catch (IOException e) { + int limit = Log.getLogLines(); + try (Stream lines = Files.lines(Path.of(logFile), MonitorProtocol.CHARSET)) { + // Streaming keeps a huge session log from being loaded into memory at once; only the + // last `limit` lines are retained. + CircularArrayList retained = new CircularArrayList<>(limit + 1); + lines.forEach(line -> { + if (retained.size() == limit) + retained.removeFirst(); + retained.addLast(new Log(line, Log4jLevel.guessLevel(line))); + }); + return List.copyOf(retained); + } catch (IOException | UncheckedIOException e) { LOG.warning("Failed to read the session log file " + logFile, e); return List.of(); } From 2a6d4a036b1a16863e6488443a5905a90f8b980c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B1=BC=E5=89=8D=E4=B8=89=E7=99=BE?= Date: Sun, 4 Oct 2026 21:56:15 +0800 Subject: [PATCH 13/44] =?UTF-8?q?docs:=20=E4=BC=98=E5=8C=96=E6=B3=A8?= =?UTF-8?q?=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/jackhuang/hmcl/monitor/MonitorCrashReporter.java | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java index cdd73555b7d..c7523f93456 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java @@ -231,17 +231,14 @@ private static LaunchOptions rebuildLaunchOptions(ResultSpec result) { return builder.create(); } - /// Reads the tail of the session log file written by the monitor into [Log] entries, guessing - /// the level of each line. At most [Log#getLogLines] lines are retained, matching the - /// in-memory log limit of the direct launch path. Returns an empty list when the log file is - /// missing or unreadable. + /// Reads the tail of the session log file into [Log] entries, guessing each line's level. At + /// most [Log#getLogLines] lines are kept, matching the direct launch path's in-memory limit. + /// Returns an empty list when the file is missing or unreadable. private static @Unmodifiable List readSessionLogs(@Nullable String logFile) { if (logFile == null) return List.of(); int limit = Log.getLogLines(); try (Stream lines = Files.lines(Path.of(logFile), MonitorProtocol.CHARSET)) { - // Streaming keeps a huge session log from being loaded into memory at once; only the - // last `limit` lines are retained. CircularArrayList retained = new CircularArrayList<>(limit + 1); lines.forEach(line -> { if (retained.size() == limit) From 9e54096a24555704884104332f3bc3c324785370 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B1=BC=E5=89=8D=E4=B8=89=E7=99=BE?= Date: Sun, 4 Oct 2026 21:56:38 +0800 Subject: [PATCH 14/44] =?UTF-8?q?fix:=20=E4=BB=93=E5=BA=93=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E5=A4=B1=E8=B4=A5=E6=97=B6=E7=AD=89=E5=BE=85=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=97=A0=E9=99=90=E6=8C=82=E8=B5=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/monitor/MonitorCrashReporter.java | 40 +++++++++---------- .../hmcl/setting/GameDirectoryManager.java | 14 ++----- 2 files changed, 22 insertions(+), 32 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java index c7523f93456..2b9ba6c540f 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java @@ -17,9 +17,6 @@ */ package org.jackhuang.hmcl.monitor; -import javafx.beans.value.ChangeListener; -import javafx.beans.value.ObservableValue; -import org.jackhuang.hmcl.game.DefaultGameRepositorySnapshot; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; @@ -30,7 +27,9 @@ import org.jackhuang.hmcl.launch.ProcessListener; import org.jackhuang.hmcl.setting.GameDirectoryID; import org.jackhuang.hmcl.setting.GameDirectoryManager; +import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.ui.GameCrashWindow; +import org.jackhuang.hmcl.util.CircularArrayList; import org.jackhuang.hmcl.util.Log4jLevel; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.platform.Architecture; @@ -38,12 +37,14 @@ import org.jackhuang.hmcl.util.platform.Platform; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; import java.io.IOException; +import java.io.UncheckedIOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; -import java.util.stream.Collectors; +import java.util.stream.Stream; import static javafx.application.Platform.runLater; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -69,11 +70,10 @@ private MonitorCrashReporter() { /// Shows the game crash window for the given monitor result file. /// - ///

Must be called on the JavaFX application thread after the main window has been set up. - /// The result file is deleted once consumed. When the launched instance cannot be resolved - /// immediately and its repository is still loading, the resolution is deferred until the - /// repository publishes its loaded snapshot. The crash window is skipped when the instance - /// still cannot be resolved; the session log file remains on disk for inspection. + ///

Must be called on the JavaFX application thread after the main window is set up, and + /// deletes the result file once consumed. When the launched instance cannot be resolved + /// immediately, the resolution is deferred until its repository finishes loading; the crash + /// window is skipped if it still cannot be resolved or the repository fails to load. /// /// @param resultFile the result file written by the monitor public static void show(Path resultFile) { @@ -109,22 +109,20 @@ public static void show(Path resultFile) { } LOG.info("Waiting for the game repository to load before showing the crash window"); - // Snapshot publication runs on the JavaFX application thread and this listener removes - // itself before scheduling the re-resolution, so the re-resolution is scheduled exactly - // once. - ChangeListener listener = new ChangeListener<>() { - @Override - public void changed(ObservableValue observable, - DefaultGameRepositorySnapshot oldValue, DefaultGameRepositorySnapshot newValue) { - repository.snapshotProperty().removeListener(this); + // Waiting on the refresh task rather than snapshot publication, so that a failed load also + // ends the wait; the loaded snapshot is published before the task completes. The selection + // flow's own refresh may overlap harmlessly (both scans are read-only). + repository.refreshAsync().whenComplete(Schedulers.defaultScheduler(), exception -> { + if (exception == null) { runLater(() -> resolveAndPresent(result, repository)); + } else { + LOG.warning("Failed to load the game repository while waiting to show the crash window", exception); + runLater(() -> giveUp(result.instanceId, result.logFile, repository)); } - }; - repository.snapshotProperty().addListener(listener); + }).start(); } - /// Returns the repository of the registered game directory with the given id, starting its - /// initial refresh when needed. + /// Returns the repository of the registered game directory with the given id. /// /// @param gameDirectoryId the persistent game directory id recorded by the monitor, or `null` /// when unknown diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectoryManager.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectoryManager.java index 2e117199f5d..f28059ded70 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectoryManager.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectoryManager.java @@ -281,10 +281,8 @@ private static HMCLGameRepository getOrCreateRepository(GameDirectory gameDirect /// Returns the repository of the registered game directory with the given id, creating it when /// needed, or `null` when no registered game directory has this id. /// - ///

Used for lookups that only know the persistent directory id, e.g. resolving an instance - /// id recorded by the HMCL monitor. The returned repository is refreshed when it is not loaded; - /// the selected repository's refresh is managed by the selection flow and is not started again - /// here. + ///

The repository is not refreshed here; callers that need loaded state should start + /// [HMCLGameRepository#refreshAsync] themselves. /// /// @param id the persistent id of the game directory to find /// @return the matching repository, or `null` when no registered game directory has this id @@ -293,13 +291,7 @@ private static HMCLGameRepository getOrCreateRepository(GameDirectory gameDirect .filter(directory -> directory.getId().equals(id)) .findFirst() .orElse(null); - if (match == null) - return null; - - HMCLGameRepository repository = getOrCreateRepository(match); - if (repository != selectedRepository.get() && !repository.isLoaded()) - repository.refreshAsync().start(); - return repository; + return match != null ? getOrCreateRepository(match) : null; } /// Adds a game directory to the per-workspace store. From fc82931e5ade22ab9702c7c514933871ef6d8eac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B1=BC=E5=89=8D=E4=B8=89=E7=99=BE?= Date: Mon, 5 Oct 2026 12:35:56 +0800 Subject: [PATCH 15/44] =?UTF-8?q?fix:=20=E4=BB=A5=E5=8D=8F=E8=AE=AE?= =?UTF-8?q?=E6=B5=81=E5=86=99=E5=85=A5=E5=A4=B1=E8=B4=A5=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E5=99=A8=E5=AD=98=E6=B4=BB=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20Linux=20=E4=B8=8B=20relaunch=20=E4=B8=8D=E8=A7=A6?= =?UTF-8?q?=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/monitor/MonitorSupervisor.java | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java index 7db00269cb6..e32d43a7e4c 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java @@ -36,7 +36,6 @@ import java.time.Instant; import java.util.List; import java.util.Objects; -import java.util.Optional; import java.util.concurrent.atomic.AtomicBoolean; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -176,8 +175,11 @@ void accept(String line, boolean isErrorStream) { try { boolean relaunch = !canceled && (spec.relaunchAlways || (spec.relaunchOnCrash && crashed)); + if (protocol.checkError()) + parentGone.set(true); + @Nullable Path resultFile = null; - if (relaunch && !isParentAlive()) { + if (relaunch && parentGone.get()) { if (crashed) resultFile = writeResult(spec, gameProcess.getPid(), processStartTime, exitCode, reportedType, logFile); relaunch(resultFile); @@ -265,18 +267,6 @@ private static Path writeResult(ProcessSpec spec, long pid, long processStartTim return resultFile; } - /// Returns whether the main launcher process that spawned this daemon is still alive. - /// - ///

On POSIX systems a reparented process's parent is the init process, which also means - /// the original parent is gone. - private static boolean isParentAlive() { - Optional parent = ProcessHandle.current().parent(); - if (parent.isEmpty()) - return false; - ProcessHandle parentHandle = parent.get(); - return parentHandle.pid() != 1 && parentHandle.isAlive(); - } - /// Starts a new HMCL main process. Fire-and-forget; the current monitor process exits right after. private static void relaunch(@Nullable Path resultFile) throws IOException { Path thisJar = JarUtils.thisJarPath(); From 20b3191ef40b82905968a9cbc4ec98558a5fb39d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B1=BC=E5=89=8D=E4=B8=89=E7=99=BE?= Date: Mon, 5 Oct 2026 12:46:45 +0800 Subject: [PATCH 16/44] =?UTF-8?q?docs:=20=E7=B2=BE=E7=AE=80=20monitor=20?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/org/jackhuang/hmcl/game/LauncherHelper.java | 7 +++---- .../org/jackhuang/hmcl/monitor/MonitorCrashReporter.java | 7 +++---- .../org/jackhuang/hmcl/game/DefaultGameRepository.java | 7 +++---- .../java/org/jackhuang/hmcl/monitor/MonitorClient.java | 7 +++---- .../java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java | 7 +++---- .../main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java | 4 ++-- 6 files changed, 17 insertions(+), 22 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java index 29cfedcb4e3..5620ba4adb4 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -294,10 +294,9 @@ private void launch0() { : new HMCLProcessListener(authInfo, launchOptions, launchingLatch, gameInstance.getVersion().compareTo(GameVersionNumber.unknown()) != 0) ); - // The HMCL monitor supervises the game process and relaunches HMCL after it - // exits, so the main launcher process may exit once the game window is up. - // With the log window shown, the main process stays alive and handles the - // game exit itself, so nothing should be relaunched. + // The monitor relaunches HMCL after the game exits, so the launcher may exit + // once the game window is up. With the log window shown, the launcher stays + // alive and handles the game exit itself, so nothing should be relaunched. launcher.setRelaunchPolicy(showLogs ? DefaultLauncher.MonitorLaunchContext.RelaunchPolicy.NEVER : switch (launcherVisibility) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java index 2b9ba6c540f..a63755c6b0a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java @@ -99,10 +99,9 @@ public static void show(Path resultFile) { return; } - // The relaunched launcher shows its main window before the game repository has finished - // loading its snapshot asynchronously, so the launched instance may exist but not be - // resolvable yet. An already-loaded repository will never gain the instance and a missing - // repository has nothing to wait for, so only an unloaded repository is waited on. + // The relaunched launcher shows its main window before the repository has finished loading + // asynchronously, so the launched instance may not be resolvable yet. A loaded repository + // will never gain the instance and a missing repository has nothing to wait for. if (repository == null || repository.isLoaded()) { giveUp(result.instanceId, result.logFile, repository); return; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java index 3b4127c5ea7..1c7aef2077e 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -172,10 +172,9 @@ protected void publishSnapshot(DefaultGameRepositorySnapshot newSnapshot) { /// Publishes `newSnapshot` together with the repository's loaded state as one atomic step. /// - /// The loaded flag is written immediately before the snapshot property is updated on the - /// JavaFX application thread, so snapshot listeners observe a ready repository when the - /// published state is loaded, and JavaFX-thread observers that see the new loaded state also - /// observe the new snapshot instead of the previous one. + /// Both are updated in one JavaFX-thread step, so snapshot listeners observe a ready + /// repository when the published state is loaded, and observers that see the new loaded state + /// also observe the new snapshot instead of the previous one. /// /// @param newSnapshot the snapshot to publish /// @param loaded the loaded state to publish together with the snapshot diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java index cb29b146841..d0db2885f21 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java @@ -104,10 +104,9 @@ public static MonitorGameProcess launchNewMonitor(MonitorLaunchContext context) if (listener != null) listener.setProcess(process); - // The reader thread keeps draining the protocol stream until the monitor exits, so that the - // exit message of a canceled launch is still dispatched and classified as interrupted. It - // must drain even without a listener, since an undrained pipe would eventually block the - // monitor. + // The reader thread must drain the protocol stream until the monitor exits even without a + // listener: the exit message of a canceled launch is dispatched here, and an undrained + // pipe would eventually block the monitor. Lang.thread(() -> { try { String line; diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java index e32d43a7e4c..50d39884e4a 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java @@ -85,7 +85,7 @@ public static boolean processArguments(String[] args) { public static void start(Path specPath) throws IOException, InterruptedException { LOG.info("*** HMCL MONITOR ***"); - // stdout is used by log output so using stderr for protocol + // stdout carries the monitor's log output, so the protocol goes over raw stderr PrintStream protocol = new PrintStream(new FileOutputStream(FileDescriptor.err), true, MonitorProtocol.CHARSET); ProcessSpec spec = JsonUtils.fromJsonFile(specPath, ProcessSpec.class); @@ -116,9 +116,8 @@ public static void start(Path specPath) throws IOException, InterruptedException ManagedProcess gameProcess = new ManagedProcess(game, spec.command); // Forward one game output line to the log file, the managed process (feeding the exit - // classification) and the protocol stream. A broken protocol stream (the main launcher - // process exited early) is not fatal: only the forwarding stops, the log file keeps - // collecting the remaining output. + // classification) and the protocol stream. A broken protocol stream (the launcher exited + // early) only stops forwarding; the log file keeps collecting the remaining output. try (BufferedWriter logWriter = Files.newBufferedWriter(logFile, MonitorProtocol.CHARSET)) { startMonitors(gameProcess, logWriter, protocol, spec, processStartTime, logFile, specPath); } diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java index 8c03c484588..5e4fb988a3d 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java @@ -45,8 +45,8 @@ final class ProcessSpec { @Nullable Map environment; /// The charset of the game process output; required. @Nullable String encoding; - /// Whether the game process should inherit the monitor's standard input, matching the direct - /// launch path's behavior when no process listener is present. + /// Whether the game process should inherit the monitor's standard input; set when no process + /// listener is present. boolean inheritStdin; /// The post-exit command to run after the game exits, already tokenized, or `null`. @Nullable List postExitCommand; From ad7f93b91cefe24d1190baf5111ef17d828cfd29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B1=BC=E5=89=8D=E4=B8=89=E7=99=BE?= Date: Tue, 6 Oct 2026 18:56:05 +0800 Subject: [PATCH 17/44] =?UTF-8?q?chore:=20=E6=B8=85=E7=90=86=E4=BB=A3?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/monitor/MonitorCrashReporter.java | 114 ++++++++++-------- .../jackhuang/hmcl/monitor/MonitorClient.java | 35 +++--- .../hmcl/monitor/MonitorSupervisor.java | 73 +++++------ .../jackhuang/hmcl/monitor/ResultSpec.java | 2 - 4 files changed, 117 insertions(+), 107 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java index a63755c6b0a..f0e859e311f 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java @@ -17,6 +17,7 @@ */ package org.jackhuang.hmcl.monitor; +import com.google.gson.JsonParseException; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; @@ -28,6 +29,7 @@ import org.jackhuang.hmcl.setting.GameDirectoryID; import org.jackhuang.hmcl.setting.GameDirectoryManager; import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.GameCrashWindow; import org.jackhuang.hmcl.util.CircularArrayList; import org.jackhuang.hmcl.util.Log4jLevel; @@ -37,7 +39,6 @@ import org.jackhuang.hmcl.util.platform.Platform; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; -import org.jetbrains.annotations.Unmodifiable; import java.io.IOException; import java.io.UncheckedIOException; @@ -80,7 +81,7 @@ public static void show(Path resultFile) { ResultSpec result; try { result = JsonUtils.fromJsonFile(resultFile, ResultSpec.class); - } catch (IOException e) { + } catch (IOException | JsonParseException e) { LOG.warning("Failed to read the monitor result file " + resultFile, e); return; } @@ -113,7 +114,13 @@ public static void show(Path resultFile) { // flow's own refresh may overlap harmlessly (both scans are read-only). repository.refreshAsync().whenComplete(Schedulers.defaultScheduler(), exception -> { if (exception == null) { - runLater(() -> resolveAndPresent(result, repository)); + runLater(() -> { + HMCLGameInstance loaded = findInstance(repository, result.instanceId); + if (loaded != null) + presentCrashWindow(result, loaded); + else + giveUp(result.instanceId, result.logFile, repository); + }); } else { LOG.warning("Failed to load the game repository while waiting to show the crash window", exception); runLater(() -> giveUp(result.instanceId, result.logFile, repository)); @@ -140,16 +147,6 @@ public static void show(Path resultFile) { return GameDirectoryManager.getOrCreateRepositoryByDirectoryId(id); } - /// Resolves the launched instance after the repository has loaded, and shows the crash window - /// when it is found. - private static void resolveAndPresent(ResultSpec result, HMCLGameRepository repository) { - HMCLGameInstance instance = findInstance(repository, result.instanceId); - if (instance != null) - presentCrashWindow(result, instance); - else - giveUp(result.instanceId, result.logFile, repository); - } - /// Logs that the launched instance could not be resolved and the crash window is skipped. private static void giveUp(@Nullable String instanceId, @Nullable String logFile, @Nullable HMCLGameRepository repository) { @@ -165,29 +162,69 @@ private static void giveUp(@Nullable String instanceId, @Nullable String logFile /// Builds and shows the crash window from the monitor result and the resolved instance. private static void presentCrashWindow(ResultSpec result, HMCLGameInstance instance) { + MonitorGameProcess process; + ProcessListener.ExitType exitType; + LaunchOptions launchOptions; + @Nullable Path logFile; try { List commands = result.commands != null ? result.commands : List.of(); ProcessHandle gameHandle = result.pid > 0 ? ProcessHandle.of(result.pid).orElse(null) : null; - MonitorGameProcess process = new MonitorGameProcess(gameHandle, commands, result.processStartTime, null); + process = new MonitorGameProcess(gameHandle, commands, result.processStartTime, null); + exitType = resolveExitType(result.exitType); + launchOptions = rebuildLaunchOptions(result); + logFile = result.logFile != null ? Path.of(result.logFile) : null; + } catch (Throwable e) { + LOG.warning("Failed to prepare the crash window for the relaunched launcher", e); + return; + } - ProcessListener.ExitType exitType = ProcessListener.ExitType.APPLICATION_ERROR; - if (result.exitType != null) { - try { - exitType = ProcessListener.ExitType.valueOf(result.exitType); - } catch (IllegalArgumentException e) { - LOG.warning("Unknown exit type in the monitor result file: " + result.exitType); - } + Task.supplyAsync(() -> { + if (logFile == null) + return List.of(); + // Keep at most Log.getLogLines() lines. + List logs = List.of(); + int limit = Log.getLogLines(); + try (Stream lines = Files.lines(logFile, MonitorProtocol.CHARSET)) { + CircularArrayList tail = new CircularArrayList<>(limit + 1); + lines.forEach(line -> { + if (tail.size() == limit) + tail.removeFirst(); + tail.addLast(line); + }); + logs = tail.stream() + .map(line -> new Log(line, Log4jLevel.guessLevel(line))) + .toList(); + } catch (IOException | UncheckedIOException e) { + LOG.warning("Failed to read the session log file " + logFile, e); } - LaunchOptions launchOptions = rebuildLaunchOptions(result); - List logs = readSessionLogs(result.logFile); - if (result.logFile != null) { - Files.deleteIfExists(Path.of(result.logFile)); + try { + Files.deleteIfExists(logFile); + } catch (IOException e) { + LOG.warning("Failed to delete the session log file " + logFile, e); } + return logs; + }).whenComplete(Schedulers.javafx(), (logs, exception) -> { + if (exception != null) + LOG.warning("Failed to read the session log file " + result.logFile, exception); + try { + new GameCrashWindow(process, exitType, instance, launchOptions, + exception == null ? logs : List.of()).show(); + } catch (Throwable e) { + LOG.warning("Failed to show the crash window for the relaunched launcher", e); + } + }).start(); + } - new GameCrashWindow(process, exitType, instance, launchOptions, logs).show(); - } catch (Throwable e) { - LOG.warning("Failed to show the crash window for the relaunched launcher", e); + /// Returns the exit type recorded in the monitor result, falling back to application error. + private static ProcessListener.ExitType resolveExitType(@Nullable String exitType) { + if (exitType == null) + return ProcessListener.ExitType.APPLICATION_ERROR; + try { + return ProcessListener.ExitType.valueOf(exitType); + } catch (IllegalArgumentException e) { + LOG.warning("Unknown exit type in the monitor result file: " + exitType); + return ProcessListener.ExitType.APPLICATION_ERROR; } } @@ -228,27 +265,6 @@ private static LaunchOptions rebuildLaunchOptions(ResultSpec result) { return builder.create(); } - /// Reads the tail of the session log file into [Log] entries, guessing each line's level. At - /// most [Log#getLogLines] lines are kept, matching the direct launch path's in-memory limit. - /// Returns an empty list when the file is missing or unreadable. - private static @Unmodifiable List readSessionLogs(@Nullable String logFile) { - if (logFile == null) - return List.of(); - int limit = Log.getLogLines(); - try (Stream lines = Files.lines(Path.of(logFile), MonitorProtocol.CHARSET)) { - CircularArrayList retained = new CircularArrayList<>(limit + 1); - lines.forEach(line -> { - if (retained.size() == limit) - retained.removeFirst(); - retained.addLast(new Log(line, Log4jLevel.guessLevel(line))); - }); - return List.copyOf(retained); - } catch (IOException | UncheckedIOException e) { - LOG.warning("Failed to read the session log file " + logFile, e); - return List.of(); - } - } - /// Resolves the launched instance by its id in the given repository. /// /// @param repository the repository of the recorded game directory, or `null` when no diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java index d0db2885f21..417e0bfb72d 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java @@ -54,14 +54,14 @@ public final class MonitorClient { /// Whether the monitor feature is enabled through the {@code HMCL_USE_HMCLMONITOR} environment variable. - private static final boolean DAEMON_ENABLED = System.getenv("HMCL_USE_HMCLMONITOR") != null; + private static final boolean MONITOR_ENABLED = System.getenv("HMCL_USE_HMCLMONITOR") != null; private MonitorClient() { } /// Returns whether the monitor feature is enabled. public static boolean isEnabled() { - return DAEMON_ENABLED; + return MONITOR_ENABLED; } /// Launches the game process via a new HMCL monitor process and returns a view of it. @@ -84,10 +84,18 @@ public static MonitorGameProcess launchNewMonitor(MonitorLaunchContext context) Path specFile = Files.createTempFile("hmcl-monitor-spec-", ".json"); JsonUtils.writeToJsonFile(specFile, buildSpec(context, options)); - Process daemon = startDaemonProcess(thisJar, specFile); - BufferedReader reader = new BufferedReader(new InputStreamReader(daemon.getErrorStream(), MonitorProtocol.CHARSET)); + List commandline = JavaProcessLauncher.buildJavaCommandLine(thisJar, + "--monitor", specFile.toAbsolutePath().toString()); + LOG.info("Starting monitor process: " + JavaProcessLauncher.maskCommandLine(commandline)); + Process monitor = new ProcessBuilder(commandline) + .directory(Paths.get("").toAbsolutePath().toFile()) + .redirectOutput(ProcessBuilder.Redirect.INHERIT) + .redirectError(ProcessBuilder.Redirect.PIPE) + .start(); - String[] handshake = readHandshake(daemon, reader); + BufferedReader reader = new BufferedReader(new InputStreamReader(monitor.getErrorStream(), MonitorProtocol.CHARSET)); + + String[] handshake = readHandshake(monitor, reader); long pid; long processStartTime; try { @@ -186,7 +194,7 @@ else if (exitType != ProcessListener.ExitType.NORMAL) /// /// @throws IOException if the monitor exits or fails before the handshake, sends a malformed /// message, or does not answer within [MonitorProtocol#HANDSHAKE_TIMEOUT] - private static String[] readHandshake(Process daemon, BufferedReader reader) throws IOException { + private static String[] readHandshake(Process monitor, BufferedReader reader) throws IOException { CompletableFuture future = new CompletableFuture<>(); Thread waiter = Lang.thread(() -> { try { @@ -225,20 +233,7 @@ private static String[] readHandshake(Process daemon, BufferedReader reader) thr // game process on its own, leaving behind an orphaned game, e.g. when the user cancels // the launch while the handshake is still pending. if (!handshakeCompleted) - daemon.destroy(); + monitor.destroy(); } } - - /// Starts the monitor process. Its stderr carries the protocol stream and is therefore piped; - /// its stdout, where the monitor's log output goes, is inherited for diagnostics. - private static Process startDaemonProcess(Path thisJar, Path specFile) throws IOException { - List commandline = JavaProcessLauncher.buildJavaCommandLine(thisJar, - "--monitor", specFile.toAbsolutePath().toString()); - LOG.info("Starting monitor process: " + JavaProcessLauncher.maskCommandLine(commandline)); - return new ProcessBuilder(commandline) - .directory(Paths.get("").toAbsolutePath().toFile()) - .redirectOutput(ProcessBuilder.Redirect.INHERIT) - .redirectError(ProcessBuilder.Redirect.PIPE) - .start(); - } } diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java index 50d39884e4a..62655cefae7 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java @@ -17,6 +17,7 @@ */ package org.jackhuang.hmcl.monitor; +import com.google.gson.JsonParseException; import org.jackhuang.hmcl.launch.ExitWaiter; import org.jackhuang.hmcl.launch.ProcessListener; import org.jackhuang.hmcl.launch.StreamPump; @@ -37,6 +38,7 @@ import java.util.List; import java.util.Objects; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BiConsumer; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -61,19 +63,18 @@ private MonitorSupervisor() { /// @return whether the arguments were consumed; the caller should exit afterward, since /// [MonitorSupervisor#start] blocks until the game process has exited public static boolean processArguments(String[] args) { - if (args.length >= 1 && args[0].equals("--monitor")) { - if (args.length != 2) { - LOG.error("Usage: --monitor "); - } else { - try { - start(Path.of(args[1])); - } catch (IOException | InterruptedException e) { - LOG.error("Failed to supervise the game process", e); - } - } + if (args.length < 1 || !args[0].equals("--monitor")) + return false; + if (args.length != 2) { + LOG.error("Usage: --monitor "); return true; } - return false; + try { + start(Path.of(args[1])); + } catch (IOException | InterruptedException e) { + LOG.error("Failed to supervise the game process", e); + } + return true; } /// Reads the [ProcessSpec] from `specPath`, creates the process it describes, supervises it @@ -88,7 +89,12 @@ public static void start(Path specPath) throws IOException, InterruptedException // stdout carries the monitor's log output, so the protocol goes over raw stderr PrintStream protocol = new PrintStream(new FileOutputStream(FileDescriptor.err), true, MonitorProtocol.CHARSET); - ProcessSpec spec = JsonUtils.fromJsonFile(specPath, ProcessSpec.class); + ProcessSpec spec; + try { + spec = JsonUtils.fromJsonFile(specPath, ProcessSpec.class); + } catch (JsonParseException e) { + throw new IOException("Malformed process spec file " + specPath, e); + } if (spec == null || spec.command == null || spec.command.isEmpty() || spec.encoding == null) throw new IOException("Malformed process spec file " + specPath); Files.delete(specPath); @@ -123,32 +129,28 @@ public static void start(Path specPath) throws IOException, InterruptedException } } - static private void startMonitors(ManagedProcess gameProcess, BufferedWriter logWriter, PrintStream protocol, + private static void startMonitors(ManagedProcess gameProcess, BufferedWriter logWriter, PrintStream protocol, ProcessSpec spec, long processStartTime, Path logFile, Path specPath) throws InterruptedException { AtomicBoolean parentGone = new AtomicBoolean(false); - class LineSink { - void accept(String line, boolean isErrorStream) { - try { - synchronized (logWriter) { - logWriter.write(line); - logWriter.newLine(); - logWriter.flush(); - } - } catch (IOException e) { - LOG.warning("Failed to write the session log", e); - } - gameProcess.addLine(line); - if (!parentGone.get()) { - protocol.println(MonitorProtocol.logMessage(isErrorStream, line)); - if (protocol.checkError()) - parentGone.set(true); + BiConsumer sink = (line, isErrorStream) -> { + try { + synchronized (logWriter) { + logWriter.write(line); + logWriter.newLine(); + logWriter.flush(); } + } catch (IOException e) { + LOG.warning("Failed to write the session log", e); } - } - - LineSink sink = new LineSink(); + gameProcess.addLine(line); + if (!parentGone.get()) { + protocol.println(MonitorProtocol.logMessage(isErrorStream, line)); + if (protocol.checkError()) + parentGone.set(true); + } + }; - Charset encoding = Charset.forName(Objects.requireNonNullElse(spec.encoding, Charset.defaultCharset().name())); + Charset encoding = Charset.forName(Objects.requireNonNull(spec.encoding)); Thread stdoutPump = Lang.thread(new StreamPump(gameProcess.getProcess().getInputStream(), line -> sink.accept(line, false), encoding), "monitor-stdout-pump", true); Thread stderrPump = Lang.thread(new StreamPump(gameProcess.getProcess().getErrorStream(), @@ -180,7 +182,7 @@ void accept(String line, boolean isErrorStream) { @Nullable Path resultFile = null; if (relaunch && parentGone.get()) { if (crashed) - resultFile = writeResult(spec, gameProcess.getPid(), processStartTime, exitCode, reportedType, logFile); + resultFile = writeResult(spec, gameProcess.getPid(), processStartTime, reportedType, logFile); relaunch(resultFile); } @@ -244,13 +246,12 @@ private static boolean readCanceled(Path specPath) { } /// Writes the [ResultSpec] consumed by a relaunched launcher to a temp file. - private static Path writeResult(ProcessSpec spec, long pid, long processStartTime, int exitCode, + private static Path writeResult(ProcessSpec spec, long pid, long processStartTime, ProcessListener.ExitType exitType, Path logFile) throws IOException { Path resultFile = Files.createTempFile("hmcl-monitor-result-", ".json"); ResultSpec result = new ResultSpec(); result.pid = pid; result.processStartTime = processStartTime; - result.exitCode = exitCode; result.exitType = exitType.name(); result.logFile = logFile.toString(); result.instanceId = spec.instanceId; diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ResultSpec.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ResultSpec.java index 8eca6d5fb04..94d1c65bf63 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ResultSpec.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ResultSpec.java @@ -30,8 +30,6 @@ public final class ResultSpec { long pid; /// The epoch millisecond at which the game process started. long processStartTime; - /// The exit code of the game process. - @Nullable Integer exitCode; /// The name of the [ProcessListener.ExitType] of the game process. @Nullable String exitType; /// The session log file collecting the game output, or `null` when unavailable. From ab354dc3ee9556966e4e29b237dbfb928b0e3dd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B1=BC=E5=89=8D=E4=B8=89=E7=99=BE?= Date: Mon, 12 Oct 2026 13:24:26 +0800 Subject: [PATCH 18/44] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84=E6=8B=89?= =?UTF-8?q?=E8=B5=B7=20monitor=20=E7=9A=84=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/jackhuang/hmcl/EntryPoint.java | 3 +- .../java/org/jackhuang/hmcl/Launcher.java | 3 +- .../jackhuang/hmcl/game/HMCLGameLauncher.java | 48 ++++++++-- .../jackhuang/hmcl/game/LauncherHelper.java | 41 ++------ .../java/org/jackhuang/hmcl/ui/LogWindow.java | 15 +-- .../hmcl/launch/DefaultLauncher.java | 93 ++----------------- .../jackhuang/hmcl/monitor/MonitorClient.java | 27 +++++- 7 files changed, 90 insertions(+), 140 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/EntryPoint.java b/HMCL/src/main/java/org/jackhuang/hmcl/EntryPoint.java index 2978f8f8f8f..05116c39937 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/EntryPoint.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/EntryPoint.java @@ -51,8 +51,7 @@ public static void main(String[] args) { createHMCLDirectories(); LOG.start(Metadata.HMCL_LOCAL_HOME.resolve("logs")); - // The monitor role must be dispatched before JavaFX is initialized, so that the monitor - // process stays lightweight and works without a JavaFX installation. + // start HMCL monitor before JavaFX initialized if (MonitorSupervisor.processArguments(args)) { exit(0); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java b/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java index 43d33fb27b1..b796b184887 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java @@ -151,8 +151,7 @@ public void start(Stage primaryStage) { WindowsNativeUtils.installWindowsAppUserModelRelaunchProperties(primaryStage); primaryStage.show(); - // This process may have been relaunched by the HMCL monitor to present the crash - // window of a supervised game process. + // check wheather to show log from HMCL monitor Path pendingCrashReport = MonitorCrashReporter.processArguments(getParameters().getRaw().toArray(new String[0])); if (pendingCrashReport != null) MonitorCrashReporter.show(pendingCrashReport); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java index 125c58fac6d..94f7bdd344f 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java @@ -17,12 +17,15 @@ */ package org.jackhuang.hmcl.game; -import org.jackhuang.hmcl.monitor.MonitorClient; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.auth.AuthInfo; import org.jackhuang.hmcl.launch.DefaultLauncher; import org.jackhuang.hmcl.launch.ProcessListener; +import org.jackhuang.hmcl.monitor.MonitorClient; +import org.jackhuang.hmcl.setting.GameSettings; +import org.jackhuang.hmcl.setting.LauncherVisibility; import org.jackhuang.hmcl.util.NativePatcher; +import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.i18n.LocaleUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.io.JarUtils; @@ -32,8 +35,13 @@ import java.io.IOException; import java.io.InputStream; -import java.nio.file.*; -import java.util.*; +import java.nio.charset.Charset; +import java.nio.file.FileVisitOption; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Locale; +import java.util.Map; import java.util.stream.Stream; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -52,10 +60,6 @@ public final class HMCLGameLauncher extends DefaultLauncher { /// @param listener process listener, or `null` to inherit IO public HMCLGameLauncher(GameInstance instance, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener) { this(instance, manifest, authInfo, options, listener, true); - - if (MonitorClient.isEnabled()) { - setLaunchMonitor(MonitorClient::launchNewMonitor); - } } /// Creates a launcher for the given instance and launch plan. @@ -164,6 +168,36 @@ public ProcessInfo launch() throws IOException, InterruptedException { return super.launch(); } + @Override + protected ProcessInfo startProcess(ProcessBuilder builder, Charset encoding, Path nativeFolder) throws IOException { + if (!MonitorClient.isEnabled()) + return super.startProcess(builder, encoding, nativeFolder); + + String gameDirectoryId = null; + MonitorClient.RelaunchPolicy relaunchPolicy = MonitorClient.RelaunchPolicy.NEVER; + if (instance instanceof HMCLGameInstance hmclGameInstance) { + gameDirectoryId = hmclGameInstance.getRepository().getGameDirectory().getId().toString(); + + GameSettings.Effective setting = hmclGameInstance.getEffectiveSettings(); + LauncherVisibility x = setting.getInheritable(GameSettings::launcherVisibilityProperty); + boolean showLogs = setting.getInheritable(GameSettings::showLogsProperty); + relaunchPolicy = showLogs + ? MonitorClient.RelaunchPolicy.NEVER + : switch (x) { + case HIDE_AND_REOPEN -> MonitorClient.RelaunchPolicy.ALWAYS; + case HIDE -> MonitorClient.RelaunchPolicy.ON_CRASH; + default -> MonitorClient.RelaunchPolicy.NEVER; + }; + } + + List postExitCommand = null; + if (StringUtils.isNotBlank(options.getPostExitCommand())) + postExitCommand = StringUtils.tokenize(options.getPostExitCommand(), getEnvVars(nativeFolder)); + + return MonitorClient.launchNewMonitor(new MonitorClient.MonitorLaunchContext(builder, listener, + encoding, options, gameDirectoryId, postExitCommand, relaunchPolicy)); + } + @Override public void makeLaunchScript(Path scriptFile) throws IOException { generateOptionsTxt(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java index 5620ba4adb4..fee0af54fd0 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -284,7 +284,7 @@ private void launch0() { LOG.info("Here's the structure of game mod directory:\n" + FileUtils.printFileStructure(gameInstance.getModsDirectory(), 10)); - HMCLGameLauncher launcher = new HMCLGameLauncher( + return new HMCLGameLauncher( gameInstance, launchManifest.get(), authInfo, @@ -293,20 +293,6 @@ private void launch0() { ? null // Unnecessary to start listening to game process output when close launcher immediately after game launched. : new HMCLProcessListener(authInfo, launchOptions, launchingLatch, gameInstance.getVersion().compareTo(GameVersionNumber.unknown()) != 0) ); - - // The monitor relaunches HMCL after the game exits, so the launcher may exit - // once the game window is up. With the log window shown, the launcher stays - // alive and handles the game exit itself, so nothing should be relaunched. - launcher.setRelaunchPolicy(showLogs - ? DefaultLauncher.MonitorLaunchContext.RelaunchPolicy.NEVER - : switch (launcherVisibility) { - case HIDE_AND_REOPEN -> DefaultLauncher.MonitorLaunchContext.RelaunchPolicy.ALWAYS; - case HIDE -> DefaultLauncher.MonitorLaunchContext.RelaunchPolicy.ON_CRASH; - default -> DefaultLauncher.MonitorLaunchContext.RelaunchPolicy.NEVER; - }); - launcher.setGameDirectoryId(gameInstance.getRepository().getGameDirectory().getId().toString()); - - return launcher; }).thenComposeAsync(launcher -> { // launcher is prev task's result if (scriptFile == null) { return Task.supplyAsync(launcher::launch); @@ -940,22 +926,6 @@ public void run() { } } - /// Called when the game window has been detected and the launch has succeeded. - /// - ///

When the game exit is handled by the HMCL monitor, the application exits right away for - /// the [LauncherVisibility#HIDE] and [LauncherVisibility#HIDE_AND_REOPEN] visibilities; - /// otherwise this method delegates to [finishLaunch]. - private void finishLaunchAfterWindowDetected() { - if (MonitorClient.isEnabled() && !showLogs - && (launcherVisibility == LauncherVisibility.HIDE - || launcherVisibility == LauncherVisibility.HIDE_AND_REOPEN)) { - launchingLatch.countDown(); - runLater(Launcher::stopApplication); - } else { - finishLaunch(); - } - } - private void finishLaunch() { switch (launcherVisibility) { case HIDE_AND_REOPEN: @@ -1026,7 +996,14 @@ public void onLog(String log, boolean isErrorStream) { try { if (!lwjgl) { lwjgl = true; - finishLaunchAfterWindowDetected(); + if (MonitorClient.isEnabled() && !showLogs + && (launcherVisibility == LauncherVisibility.HIDE + || launcherVisibility == LauncherVisibility.HIDE_AND_REOPEN)) { + launchingLatch.countDown(); + runLater(Launcher::stopApplication); + } else { + finishLaunch(); + } } } finally { lock.unlock(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/LogWindow.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/LogWindow.java index 79fa1182bbf..86b2f877ec3 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/LogWindow.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/LogWindow.java @@ -433,15 +433,16 @@ protected void updateItem(Log item, boolean empty) { clearButton.setOnAction(e -> getSkinnable().onClear()); hBox.getChildren().setAll(autoScrollCheckBox, wrapTextCheckBox, exportLogsButton, terminateButton, exportDumpPane, clearButton); - ProcessHandle gameHandle = control.getGameProcess().getHandle(); - Runnable disableButtons = () -> { + var handle = control.getGameProcess().getHandle(); + if (handle != null) { + handle.onExit().thenRunAsync(() -> { + terminateButton.setDisable(true); + exportDumpButton.setDisable(true); + }, Schedulers.javafx()); + } else { terminateButton.setDisable(true); exportDumpButton.setDisable(true); - }; - if (gameHandle != null) - gameHandle.onExit().thenRunAsync(disableButtons, Schedulers.javafx()); - else if (!control.getGameProcess().isRunning()) - disableButtons.run(); + } vbox.getChildren().add(bottom); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java index 1cd52e026e9..f6258b35115 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java @@ -53,58 +53,6 @@ /// @author huangyuhui public class DefaultLauncher extends Launcher { - /// Launches the game process described by the context via the HMCL monitor process. - @FunctionalInterface - public interface LaunchMonitorFunction { - /// @param context the launch context prepared by [DefaultLauncher] - /// @return a view of the game process launched and supervised by the monitor - ProcessInfo apply(MonitorLaunchContext context) throws IOException; - } - - /// The context needed to launch a game process via the HMCL monitor process. - /// - /// @param builder the process builder prepared by [DefaultLauncher]; its command line, - /// working directory and environment are already complete - /// @param listener the process listener that receives game output and exit events, - /// or `null` if game output is not needed - /// @param encoding the charset of the game process output - /// @param options the launch options, providing instance identity for the result report - /// @param gameDirectoryId the persistent id of the registered game directory owning the - /// launched instance, recorded for the result report, or `null` when - /// unknown - /// @param postExitCommand the post-exit command to run after the game exits, already tokenized - /// and with placeholders expanded, or `null` if not configured - /// @param relaunchPolicy what the monitor should do after the game process exits - public record MonitorLaunchContext( - ProcessBuilder builder, - @Nullable ProcessListener listener, - Charset encoding, - LaunchOptions options, - @Nullable String gameDirectoryId, - @Nullable List postExitCommand, - RelaunchPolicy relaunchPolicy) { - - /// What the HMCL monitor process should do after the game process exits. - public enum RelaunchPolicy { - /// Never relaunch the launcher after the game exits. - NEVER, - /// Relaunch the launcher when the game exits abnormally, so that it presents the - /// crash window. - ON_CRASH, - /// Relaunch the launcher unconditionally after the game exits, e.g. for the - /// "hide and reopen" launcher visibility. - ALWAYS - } - } - - /// If non-null, game processes are created via the HMCL monitor process instead of directly. - private @Nullable LaunchMonitorFunction launchMonitor = null; - /// The relaunch policy applied by the HMCL monitor process after the game process exits. - private MonitorLaunchContext.RelaunchPolicy relaunchPolicy = MonitorLaunchContext.RelaunchPolicy.NEVER; - /// The persistent id of the registered game directory owning the launched instance, recorded - /// for the monitor result report. - private @Nullable String gameDirectoryId; - public DefaultLauncher(GameInstance instance, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) { super(instance, manifest, authInfo, options, listener, daemon); } @@ -713,24 +661,6 @@ private Path getNativeFolder() { return Path.of(options.getNativesDir()); } - /// Sets the monitor launcher; when non-null, [launching][#launch] delegates process creation to - /// the HMCL monitor process. - public void setLaunchMonitor(@Nullable LaunchMonitorFunction launchMonitor) { - this.launchMonitor = launchMonitor; - } - - /// Sets what the HMCL monitor process should do after the game process exits. Ignored unless - /// [a monitor launcher is set][#setLaunchMonitor]. - public void setRelaunchPolicy(MonitorLaunchContext.RelaunchPolicy relaunchPolicy) { - this.relaunchPolicy = relaunchPolicy; - } - - /// Sets the persistent id of the registered game directory owning the launched instance, - /// recorded in the monitor result. Ignored unless [a monitor launcher is set][#setLaunchMonitor]. - public void setGameDirectoryId(@Nullable String gameDirectoryId) { - this.gameDirectoryId = gameDirectoryId; - } - @Override public ProcessInfo launch() throws IOException, InterruptedException { Path nativeFolder = getNativeFolder(); @@ -764,7 +694,6 @@ public ProcessInfo launch() throws IOException, InterruptedException { SystemUtils.callExternalProcess(builder); } - Process process; try { ProcessBuilder builder = new ProcessBuilder(rawCommandLine).directory(runDirectory.toFile()); if (listener == null) { @@ -775,29 +704,21 @@ public ProcessInfo launch() throws IOException, InterruptedException { builder.environment().putAll(getEnvVars(nativeFolder)); - if (launchMonitor != null) { - // The post-exit command must be executed by the monitor, since the main launcher - // process may exit before the game process does. - List postExitCommand = null; - if (StringUtils.isNotBlank(options.getPostExitCommand())) - postExitCommand = StringUtils.tokenize(options.getPostExitCommand(), getEnvVars(nativeFolder)); - - return launchMonitor.apply(new MonitorLaunchContext(builder, listener, command.encoding, options, - gameDirectoryId, postExitCommand, relaunchPolicy)); - } - - process = builder.start(); + return startProcess(builder, command.encoding, nativeFolder); } catch (IOException e) { throw new ProcessCreationException(e); } + } - ManagedProcess p = new ManagedProcess(process, rawCommandLine); + protected ProcessInfo startProcess(ProcessBuilder builder, Charset encoding, Path nativeFolder) throws IOException { + Process process = builder.start(); + ManagedProcess p = new ManagedProcess(process, builder.command()); if (listener != null) - startMonitors(p, nativeFolder, listener, command.encoding, daemon); + startMonitors(p, nativeFolder, listener, encoding, daemon); return p; } - private Map getEnvVars(Path nativeFolder) { + protected Map getEnvVars(Path nativeFolder) { String versionName = Optional.ofNullable(options.getVersionName()).orElse(manifest.id().toString()); Map env = new LinkedHashMap<>(); diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java index 417e0bfb72d..e0899fc42e6 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java @@ -23,18 +23,19 @@ import org.jackhuang.hmcl.event.ProcessStoppedEvent; import org.jackhuang.hmcl.game.LaunchOptions; import org.jackhuang.hmcl.java.JavaRuntime; -import org.jackhuang.hmcl.launch.DefaultLauncher.MonitorLaunchContext; import org.jackhuang.hmcl.launch.ProcessListener; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.JarUtils; import org.jackhuang.hmcl.util.platform.JavaProcessLauncher; +import org.jackhuang.hmcl.util.platform.ProcessInfo; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; +import java.nio.charset.Charset; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -64,6 +65,24 @@ public static boolean isEnabled() { return MONITOR_ENABLED; } + public record MonitorLaunchContext( + ProcessBuilder builder, + @Nullable ProcessListener listener, + Charset encoding, + LaunchOptions options, + @Nullable String gameDirectoryId, + @Nullable List postExitCommand, + RelaunchPolicy relaunchPolicy) {} + + public enum RelaunchPolicy { + /// Never relaunch the launcher after the game exits. + NEVER, + /// Relaunch the launcher when the game exits abnormally. + ON_CRASH, + /// Relaunch the launcher unconditionally after the game exits. + ALWAYS + } + /// Launches the game process via a new HMCL monitor process and returns a view of it. /// ///

Blocks until the monitor has created the game process and reported its id back; game @@ -140,9 +159,9 @@ private static ProcessSpec buildSpec(MonitorLaunchContext context, LaunchOptions spec.encoding = context.encoding().name(); spec.inheritStdin = context.listener() == null; spec.postExitCommand = context.postExitCommand(); - MonitorLaunchContext.RelaunchPolicy relaunchPolicy = context.relaunchPolicy(); - spec.relaunchAlways = relaunchPolicy == MonitorLaunchContext.RelaunchPolicy.ALWAYS; - spec.relaunchOnCrash = relaunchPolicy == MonitorLaunchContext.RelaunchPolicy.ON_CRASH; + RelaunchPolicy relaunchPolicy = context.relaunchPolicy(); + spec.relaunchAlways = relaunchPolicy == RelaunchPolicy.ALWAYS; + spec.relaunchOnCrash = relaunchPolicy == RelaunchPolicy.ON_CRASH; if (options.getInstanceId() != null) spec.instanceId = options.getInstanceId().toString(); spec.gameDirectoryId = context.gameDirectoryId(); From 853374faf756b7867f3445cbdba4da7cd20c5c51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B1=BC=E5=89=8D=E4=B8=89=E7=99=BE?= Date: Mon, 12 Oct 2026 13:39:10 +0800 Subject: [PATCH 19/44] =?UTF-8?q?chore:=20=E4=BC=98=E5=8C=96=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/jackhuang/hmcl/game/HMCLGameLauncher.java | 8 ++------ .../java/org/jackhuang/hmcl/monitor/MonitorClient.java | 1 - 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java index 94f7bdd344f..97b8af730e6 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java @@ -36,12 +36,8 @@ import java.io.IOException; import java.io.InputStream; import java.nio.charset.Charset; -import java.nio.file.FileVisitOption; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; -import java.util.Locale; -import java.util.Map; +import java.nio.file.*; +import java.util.*; import java.util.stream.Stream; import static org.jackhuang.hmcl.util.logging.Logger.LOG; diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java index e0899fc42e6..5c54840b836 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java @@ -28,7 +28,6 @@ import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.JarUtils; import org.jackhuang.hmcl.util.platform.JavaProcessLauncher; -import org.jackhuang.hmcl.util.platform.ProcessInfo; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; From 28008aa992b78f7494ae157d70712c18f337abbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B1=BC=E5=89=8D=E4=B8=89=E7=99=BE?= Date: Mon, 12 Oct 2026 17:47:04 +0800 Subject: [PATCH 20/44] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84=20MonitorC?= =?UTF-8?q?rashReporter=20=E5=92=8C=20spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/monitor/MonitorCrashReporter.java | 203 ++++-------------- .../hmcl/setting/GameDirectoryManager.java | 16 -- .../jackhuang/hmcl/monitor/MonitorClient.java | 88 ++++---- .../hmcl/monitor/MonitorGameProcess.java | 23 +- .../hmcl/monitor/MonitorSupervisor.java | 33 +-- .../jackhuang/hmcl/monitor/ProcessSpec.java | 56 ++--- .../jackhuang/hmcl/monitor/ResultSpec.java | 29 +-- 7 files changed, 142 insertions(+), 306 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java index f0e859e311f..6f9ca053f92 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java @@ -22,10 +22,8 @@ import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.game.Log; -import org.jackhuang.hmcl.game.LaunchOptions; -import org.jackhuang.hmcl.java.JavaInfo; -import org.jackhuang.hmcl.java.JavaRuntime; import org.jackhuang.hmcl.launch.ProcessListener; +import org.jackhuang.hmcl.setting.GameDirectory; import org.jackhuang.hmcl.setting.GameDirectoryID; import org.jackhuang.hmcl.setting.GameDirectoryManager; import org.jackhuang.hmcl.task.Schedulers; @@ -34,9 +32,6 @@ import org.jackhuang.hmcl.util.CircularArrayList; import org.jackhuang.hmcl.util.Log4jLevel; import org.jackhuang.hmcl.util.gson.JsonUtils; -import org.jackhuang.hmcl.util.platform.Architecture; -import org.jackhuang.hmcl.util.platform.OperatingSystem; -import org.jackhuang.hmcl.util.platform.Platform; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; @@ -45,6 +40,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.Objects; +import java.util.Optional; import java.util.stream.Stream; import static javafx.application.Platform.runLater; @@ -58,25 +55,12 @@ public final class MonitorCrashReporter { private MonitorCrashReporter() { } - /// Handles the `--crash-report` argument of a launcher process relaunched by the monitor. - /// - /// @param args the process arguments - /// @return the path of the monitor result file to present, or `null` when the arguments do not - /// request a crash report public static @Nullable Path processArguments(String[] args) { if (args.length == 2 && args[0].equals("--crash-report")) return Path.of(args[1]); return null; } - /// Shows the game crash window for the given monitor result file. - /// - ///

Must be called on the JavaFX application thread after the main window is set up, and - /// deletes the result file once consumed. When the launched instance cannot be resolved - /// immediately, the resolution is deferred until its repository finishes loading; the crash - /// window is skipped if it still cannot be resolved or the repository fails to load. - /// - /// @param resultFile the result file written by the monitor public static void show(Path resultFile) { ResultSpec result; try { @@ -90,66 +74,54 @@ public static void show(Path resultFile) { } catch (IOException e) { LOG.warning("Failed to delete the monitor result file " + resultFile, e); } - if (result == null) + if (result == null || result.launchOptions == null || result.gameDirectoryId == null) return; - HMCLGameRepository repository = resolveRepository(result.gameDirectoryId); - HMCLGameInstance instance = findInstance(repository, result.instanceId); + // find repository + GameDirectoryID id = GameDirectoryID.parse(result.gameDirectoryId); + Optional match = GameDirectoryManager.getGameDirectories().stream() + .filter(directory -> directory.getId().equals(id)) + .findFirst(); + if (match.isEmpty()) + return; + GameDirectoryManager.setSelectedGameDirectory(match.get()); + HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository(); + + // find instance + GameInstanceID instanceId = result.launchOptions.getInstanceId(); + HMCLGameInstance instance = repository.findInstance(instanceId); + + // instance found if (instance != null) { presentCrashWindow(result, instance); return; } - // The relaunched launcher shows its main window before the repository has finished loading - // asynchronously, so the launched instance may not be resolvable yet. A loaded repository - // will never gain the instance and a missing repository has nothing to wait for. - if (repository == null || repository.isLoaded()) { - giveUp(result.instanceId, result.logFile, repository); + // repository found but cannot find instance + if (repository.isLoaded()) { + logFail(instanceId, result.logFile, repository); return; } LOG.info("Waiting for the game repository to load before showing the crash window"); - // Waiting on the refresh task rather than snapshot publication, so that a failed load also - // ends the wait; the loaded snapshot is published before the task completes. The selection - // flow's own refresh may overlap harmlessly (both scans are read-only). repository.refreshAsync().whenComplete(Schedulers.defaultScheduler(), exception -> { if (exception == null) { runLater(() -> { - HMCLGameInstance loaded = findInstance(repository, result.instanceId); + HMCLGameInstance loaded = repository.findInstance(instanceId); if (loaded != null) presentCrashWindow(result, loaded); else - giveUp(result.instanceId, result.logFile, repository); + logFail(instanceId, result.logFile, repository); }); } else { LOG.warning("Failed to load the game repository while waiting to show the crash window", exception); - runLater(() -> giveUp(result.instanceId, result.logFile, repository)); + logFail(instanceId, result.logFile, repository); } }).start(); } - /// Returns the repository of the registered game directory with the given id. - /// - /// @param gameDirectoryId the persistent game directory id recorded by the monitor, or `null` - /// when unknown - /// @return the matching repository, or `null` when no registered game directory has this id, - /// e.g. because the user removed the game directory while the game was running - private static @Nullable HMCLGameRepository resolveRepository(@Nullable String gameDirectoryId) { - if (gameDirectoryId == null) - return null; - GameDirectoryID id; - try { - id = GameDirectoryID.parse(gameDirectoryId); - } catch (IllegalArgumentException e) { - LOG.warning("Malformed game directory id in the monitor result file: " + gameDirectoryId); - return null; - } - return GameDirectoryManager.getOrCreateRepositoryByDirectoryId(id); - } - - /// Logs that the launched instance could not be resolved and the crash window is skipped. - private static void giveUp(@Nullable String instanceId, @Nullable String logFile, - @Nullable HMCLGameRepository repository) { + private static void logFail(GameInstanceID instanceId, @Nullable String logFile, + @Nullable HMCLGameRepository repository) { LOG.warning("Cannot resolve the launched instance " + instanceId + " (" + (repository != null ? repository.getSnapshot().getInstanceCount() + " instances, loaded=" @@ -160,129 +132,42 @@ private static void giveUp(@Nullable String instanceId, @Nullable String logFile + " The session log is at " + logFile); } - /// Builds and shows the crash window from the monitor result and the resolved instance. private static void presentCrashWindow(ResultSpec result, HMCLGameInstance instance) { - MonitorGameProcess process; - ProcessListener.ExitType exitType; - LaunchOptions launchOptions; - @Nullable Path logFile; - try { - List commands = result.commands != null ? result.commands : List.of(); - ProcessHandle gameHandle = result.pid > 0 ? ProcessHandle.of(result.pid).orElse(null) : null; - process = new MonitorGameProcess(gameHandle, commands, result.processStartTime, null); - exitType = resolveExitType(result.exitType); - launchOptions = rebuildLaunchOptions(result); - logFile = result.logFile != null ? Path.of(result.logFile) : null; - } catch (Throwable e) { - LOG.warning("Failed to prepare the crash window for the relaunched launcher", e); - return; - } + MonitorGameProcess process = new MonitorGameProcess( + ProcessHandle.of(result.pid).orElse(null), + Objects.requireNonNullElseGet(result.commands, List::of), + result.processStartTime, + null); // pretend this is the real process. + @Nullable Path logFile = result.logFile != null ? Path.of(result.logFile) : null; + + ProcessListener.ExitType exitType = ProcessListener.ExitType.valueOf(result.exitType); Task.supplyAsync(() -> { if (logFile == null) return List.of(); - // Keep at most Log.getLogLines() lines. - List logs = List.of(); + + // limit the lines of log int limit = Log.getLogLines(); + CircularArrayList tail = new CircularArrayList<>(limit); try (Stream lines = Files.lines(logFile, MonitorProtocol.CHARSET)) { - CircularArrayList tail = new CircularArrayList<>(limit + 1); lines.forEach(line -> { if (tail.size() == limit) tail.removeFirst(); tail.addLast(line); }); - logs = tail.stream() - .map(line -> new Log(line, Log4jLevel.guessLevel(line))) - .toList(); } catch (IOException | UncheckedIOException e) { LOG.warning("Failed to read the session log file " + logFile, e); } - try { - Files.deleteIfExists(logFile); - } catch (IOException e) { - LOG.warning("Failed to delete the session log file " + logFile, e); - } - return logs; + Files.deleteIfExists(logFile); + return tail.stream() + .map(line -> new Log(line, Log4jLevel.guessLevel(line))) + .toList(); }).whenComplete(Schedulers.javafx(), (logs, exception) -> { if (exception != null) LOG.warning("Failed to read the session log file " + result.logFile, exception); - try { - new GameCrashWindow(process, exitType, instance, launchOptions, - exception == null ? logs : List.of()).show(); - } catch (Throwable e) { - LOG.warning("Failed to show the crash window for the relaunched launcher", e); - } + new GameCrashWindow(process, exitType, instance, Objects.requireNonNull(result.launchOptions), + exception == null ? logs : List.of()).show(); }).start(); } - - /// Returns the exit type recorded in the monitor result, falling back to application error. - private static ProcessListener.ExitType resolveExitType(@Nullable String exitType) { - if (exitType == null) - return ProcessListener.ExitType.APPLICATION_ERROR; - try { - return ProcessListener.ExitType.valueOf(exitType); - } catch (IllegalArgumentException e) { - LOG.warning("Unknown exit type in the monitor result file: " + exitType); - return ProcessListener.ExitType.APPLICATION_ERROR; - } - } - - /// Rebuilds a [LaunchOptions] carrying the display-relevant fields recorded by the monitor. - /// - /// The [JavaRuntime] is guaranteed to be present, since the crash window dereferences it for - /// display; a failure to rebuild the recorded one falls back to the current runtime. - private static LaunchOptions rebuildLaunchOptions(ResultSpec result) { - LaunchOptions.Builder builder = new LaunchOptions.Builder(); - if (result.gameDir != null) { - try { - builder.setGameDir(Path.of(result.gameDir)); - } catch (IllegalArgumentException e) { - LOG.warning("Malformed game directory in the monitor result file: " + result.gameDir); - } - } - if (result.versionName != null) - builder.setVersionName(result.versionName); - if (result.maxMemory != null) - builder.setMaxMemory(result.maxMemory); - - JavaRuntime java = null; - if (result.javaBinary != null && result.javaVersion != null && result.javaArchitecture != null) { - try { - Architecture architecture = Architecture.parseArchName(result.javaArchitecture); - JavaInfo info = new JavaInfo.Builder( - Platform.getPlatform(OperatingSystem.CURRENT_OS, architecture), result.javaVersion).build(); - java = JavaRuntime.of(Path.of(result.javaBinary), info, false); - } catch (Throwable e) { - LOG.warning("Failed to rebuild the Java runtime from the monitor result file", e); - } - } - if (java == null) { - LOG.warning("Falling back to the current Java runtime for the crash window display"); - java = JavaRuntime.getDefault(); - } - builder.setJava(java); - return builder.create(); - } - - /// Resolves the launched instance by its id in the given repository. - /// - /// @param repository the repository of the recorded game directory, or `null` when no - /// registered game directory matches - /// @param instanceId the instance id recorded by the monitor, or `null` when unknown - /// @return the instance, or `null` when it cannot be resolved - private static @Nullable HMCLGameInstance findInstance(@Nullable HMCLGameRepository repository, - @Nullable String instanceId) { - if (repository == null || instanceId == null) - return null; - - GameInstanceID id; - try { - id = new GameInstanceID(instanceId); - } catch (IllegalArgumentException e) { - LOG.warning("Malformed instance id in the monitor result file: " + instanceId); - return null; - } - return repository.findInstance(id); - } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectoryManager.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectoryManager.java index 9a6a1758f70..33dae51ae90 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectoryManager.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectoryManager.java @@ -275,22 +275,6 @@ private static HMCLGameRepository getOrCreateRepository(GameDirectory gameDirect return repositories.computeIfAbsent(gameDirectory, HMCLGameRepository::new); } - /// Returns the repository of the registered game directory with the given id, creating it when - /// needed, or `null` when no registered game directory has this id. - /// - ///

The repository is not refreshed here; callers that need loaded state should start - /// [HMCLGameRepository#refreshAsync] themselves. - /// - /// @param id the persistent id of the game directory to find - /// @return the matching repository, or `null` when no registered game directory has this id - public static @Nullable HMCLGameRepository getOrCreateRepositoryByDirectoryId(GameDirectoryID id) { - GameDirectory match = mergedGameDirectoriesUnmodifiable.stream() - .filter(directory -> directory.getId().equals(id)) - .findFirst() - .orElse(null); - return match != null ? getOrCreateRepository(match) : null; - } - /// Adds a game directory to the per-workspace store. public static void addLocalGameDirectory(GameDirectory gameDirectory) { if (SettingsManager.isLocalGameDirectoriesReadOnly()) { diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java index 5c54840b836..959c53342c0 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java @@ -17,15 +17,20 @@ */ package org.jackhuang.hmcl.monitor; +import com.google.gson.ExclusionStrategy; +import com.google.gson.FieldAttributes; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; import org.jackhuang.hmcl.event.EventBus; import org.jackhuang.hmcl.event.JVMLaunchFailedEvent; import org.jackhuang.hmcl.event.ProcessExitedAbnormallyEvent; import org.jackhuang.hmcl.event.ProcessStoppedEvent; import org.jackhuang.hmcl.game.LaunchOptions; -import org.jackhuang.hmcl.java.JavaRuntime; +import org.jackhuang.hmcl.game.ProxyOption; +import org.jackhuang.hmcl.game.Renderer; import org.jackhuang.hmcl.launch.ProcessListener; import org.jackhuang.hmcl.util.Lang; -import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.gson.*; import org.jackhuang.hmcl.util.io.JarUtils; import org.jackhuang.hmcl.util.platform.JavaProcessLauncher; import org.jetbrains.annotations.NotNullByDefault; @@ -38,9 +43,9 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.LinkedHashMap; +import java.time.Instant; import java.util.List; +import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; @@ -53,13 +58,11 @@ @NotNullByDefault public final class MonitorClient { - /// Whether the monitor feature is enabled through the {@code HMCL_USE_HMCLMONITOR} environment variable. private static final boolean MONITOR_ENABLED = System.getenv("HMCL_USE_HMCLMONITOR") != null; private MonitorClient() { } - /// Returns whether the monitor feature is enabled. public static boolean isEnabled() { return MONITOR_ENABLED; } @@ -82,25 +85,42 @@ public enum RelaunchPolicy { ALWAYS } - /// Launches the game process via a new HMCL monitor process and returns a view of it. - /// - ///

Blocks until the monitor has created the game process and reported its id back; game - /// output and the exit event are dispatched to the [listener][MonitorLaunchContext#listener()] - /// asynchronously afterward. - /// - /// @param context the launch context prepared by [org.jackhuang.hmcl.launch.DefaultLauncher] - /// @return a view of the game process managed by the monitor - /// @throws IOException if the monitor process could not be started, failed to create the game - /// process, or did not complete the handshake in time + private static final Gson gsonForGameOptions = new GsonBuilder() + .enableComplexMapKeySerialization() + .setPrettyPrinting() + .registerTypeAdapter(Instant.class, InstantTypeAdapter.INSTANCE) + .registerTypeAdapter(UUID.class, UUIDTypeAdapter.INSTANCE) + .registerTypeHierarchyAdapter(Path.class, PathTypeAdapter.INSTANCE) // fix Path + .registerTypeAdapterFactory(ValidationTypeAdapterFactory.INSTANCE) + .registerTypeAdapterFactory(LowerCaseEnumTypeAdapter.FACTORY) + .registerTypeAdapterFactory(JsonTypeAdapterFactory.INSTANCE) + .addSerializationExclusionStrategy(new ExclusionStrategy() { + @Override + public boolean shouldSkipField(FieldAttributes f) { + return false; + } + + @Override + public boolean shouldSkipClass(Class clazz) { + return ProxyOption.class.isAssignableFrom(clazz) + || Renderer.class.isAssignableFrom(clazz); // ignoring these + } + }) + .create(); + + static void writeSpec(Object obj, Path specFile) throws IOException { + try (var writer = Files.newBufferedWriter(specFile)) { + gsonForGameOptions.toJson(obj, writer); + } + } + public static MonitorGameProcess launchNewMonitor(MonitorLaunchContext context) throws IOException { Path thisJar = JarUtils.thisJarPath(); if (thisJar == null) throw new IOException("Failed to find current HMCL location"); - LaunchOptions options = context.options(); - Path specFile = Files.createTempFile("hmcl-monitor-spec-", ".json"); - JsonUtils.writeToJsonFile(specFile, buildSpec(context, options)); + writeSpec(new ProcessSpec(context), specFile); List commandline = JavaProcessLauncher.buildJavaCommandLine(thisJar, "--monitor", specFile.toAbsolutePath().toString()); @@ -147,36 +167,6 @@ public static MonitorGameProcess launchNewMonitor(MonitorLaunchContext context) return process; } - /// Builds the [ProcessSpec] to exchange with the monitor as JSON. - private static ProcessSpec buildSpec(MonitorLaunchContext context, LaunchOptions options) { - ProcessBuilder builder = context.builder(); - ProcessSpec spec = new ProcessSpec(); - spec.command = new ArrayList<>(builder.command()); - if (builder.directory() != null) - spec.directory = builder.directory().getAbsolutePath(); - spec.environment = new LinkedHashMap<>(builder.environment()); - spec.encoding = context.encoding().name(); - spec.inheritStdin = context.listener() == null; - spec.postExitCommand = context.postExitCommand(); - RelaunchPolicy relaunchPolicy = context.relaunchPolicy(); - spec.relaunchAlways = relaunchPolicy == RelaunchPolicy.ALWAYS; - spec.relaunchOnCrash = relaunchPolicy == RelaunchPolicy.ON_CRASH; - if (options.getInstanceId() != null) - spec.instanceId = options.getInstanceId().toString(); - spec.gameDirectoryId = context.gameDirectoryId(); - spec.versionName = options.getVersionName(); - if (options.getGameDir() != null) - spec.gameDir = options.getGameDir().toString(); - spec.maxMemory = options.getMaxMemory(); - JavaRuntime java = options.getJava(); - if (java != null) { - spec.javaBinary = java.getBinary().toString(); - spec.javaVersion = java.getVersion(); - spec.javaArchitecture = java.getArchitecture().name(); - } - return spec; - } - /// Dispatches one protocol message received from the monitor. private static void dispatchProtocolMessage(String line, @Nullable ProcessListener listener, MonitorGameProcess process) { String[] parts = line.split("\t", 3); diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java index e135f4496eb..64c51eef307 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java @@ -92,25 +92,14 @@ public boolean isRunning() { @Override public void stop() { - // The monitor cannot be interrupted the way the direct launch path's ExitWaiter is; the - // rewritten spec file is the cancellation signal, so write it before destroying the process. - Path specFile = this.specFile; if (specFile != null) - writeCancelSpec(specFile); - ProcessHandle handle = this.handle; + try { + // notify the monitor + JsonUtils.writeToJsonFile(specFile, ProcessSpec.canceledSpec()); + } catch (IOException e) { + LOG.warning("Failed to rewrite the monitor spec file " + specFile + " to mark the cancellation", e); + } if (handle != null) handle.destroy(); } - - /// Rewrites the consumed spec file to mark the game process as manually canceled. Best-effort: - /// on a failure the monitor classifies the exit as it does for any other abnormal exit. - private static void writeCancelSpec(Path specFile) { - try { - ProcessSpec spec = new ProcessSpec(); - spec.canceled = true; - JsonUtils.writeToJsonFile(specFile, spec); - } catch (IOException e) { - LOG.warning("Failed to rewrite the monitor spec file " + specFile + " to mark the cancellation", e); - } - } } diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java index 62655cefae7..9c1bf25fe94 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java @@ -181,8 +181,11 @@ private static void startMonitors(ManagedProcess gameProcess, BufferedWriter log @Nullable Path resultFile = null; if (relaunch && parentGone.get()) { - if (crashed) - resultFile = writeResult(spec, gameProcess.getPid(), processStartTime, reportedType, logFile); + if (crashed) { + resultFile = Files.createTempFile("hmcl-monitor-result-", ".json"); + ResultSpec result = new ResultSpec(spec, gameProcess.getPid(), processStartTime, exitType, logFile); + MonitorClient.writeSpec(result, resultFile); + } relaunch(resultFile); } @@ -212,8 +215,8 @@ private static void runPostExitCommand(ProcessSpec spec) { // The direct launch path runs the post-exit command in the game directory, which is // not necessarily the game process's working directory when version isolation is on. ProcessBuilder builder = new ProcessBuilder(spec.postExitCommand); - if (spec.gameDir != null && !spec.gameDir.isEmpty()) - builder.directory(new File(spec.gameDir)); + if (spec.launchOptions != null) + builder.directory(spec.launchOptions.getGameDir().toFile()); else if (spec.directory != null && !spec.directory.isEmpty()) builder.directory(new File(spec.directory)); if (spec.environment != null) @@ -245,28 +248,6 @@ private static boolean readCanceled(Path specPath) { return canceled; } - /// Writes the [ResultSpec] consumed by a relaunched launcher to a temp file. - private static Path writeResult(ProcessSpec spec, long pid, long processStartTime, - ProcessListener.ExitType exitType, Path logFile) throws IOException { - Path resultFile = Files.createTempFile("hmcl-monitor-result-", ".json"); - ResultSpec result = new ResultSpec(); - result.pid = pid; - result.processStartTime = processStartTime; - result.exitType = exitType.name(); - result.logFile = logFile.toString(); - result.instanceId = spec.instanceId; - result.gameDirectoryId = spec.gameDirectoryId; - result.versionName = spec.versionName; - result.gameDir = spec.gameDir; - result.maxMemory = spec.maxMemory; - result.javaBinary = spec.javaBinary; - result.javaVersion = spec.javaVersion; - result.javaArchitecture = spec.javaArchitecture; - result.commands = spec.command; - JsonUtils.writeToJsonFile(resultFile, result); - return resultFile; - } - /// Starts a new HMCL main process. Fire-and-forget; the current monitor process exits right after. private static void relaunch(@Nullable Path resultFile) throws IOException { Path thisJar = JarUtils.thisJarPath(); diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java index 5e4fb988a3d..85238b27119 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java @@ -17,24 +17,16 @@ */ package org.jackhuang.hmcl.monitor; +import org.jackhuang.hmcl.game.LaunchOptions; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /// The specification of the game process to supervise, exchanged with the monitor process as JSON. -/// -/// ```json -/// { -/// "command": ["javaw", "-Xmx4G", "net.minecraft.client.main.Main"], -/// "directory": "D:\\Game\\.minecraft", -/// "environment": { "APPDATA": "C:\\Users\\user\\AppData\\Roaming" }, -/// "encoding": "UTF-8", -/// "relaunchAlways": false, -/// "relaunchOnCrash": true -/// } -/// ``` @NotNullByDefault final class ProcessSpec { /// The command line to execute, passed to [ProcessBuilder] verbatim. @@ -57,21 +49,35 @@ final class ProcessSpec { boolean relaunchAlways; /// Whether to relaunch the launcher after the game exits abnormally. boolean relaunchOnCrash; - /// The id of the launched instance, or `null` when unknown. - @Nullable String instanceId; /// The persistent id of the registered game directory owning the launched instance, or `null` /// when unknown. @Nullable String gameDirectoryId; - /// The display name of the launched version, or `null` when unknown. - @Nullable String versionName; - /// The game directory of the launched instance, or `null` when unknown. - @Nullable String gameDir; - /// The maximum memory of the game process in MiB, or `null` when unset. - @Nullable Integer maxMemory; - /// The binary path of the Java runtime the game runs on, or `null` when unknown. - @Nullable String javaBinary; - /// The version string of the Java runtime the game runs on, or `null` when unknown. - @Nullable String javaVersion; - /// The architecture name of the Java runtime the game runs on, or `null` when unknown. - @Nullable String javaArchitecture; + + @Nullable LaunchOptions launchOptions; + + public static ProcessSpec canceledSpec() { + ProcessSpec spec = new ProcessSpec(); + spec.canceled = true; + return spec; + } + + public ProcessSpec() {} + + public ProcessSpec(MonitorClient.MonitorLaunchContext context) { + ProcessBuilder builder = context.builder(); + + command = new ArrayList<>(builder.command()); + if (builder.directory() != null) + directory = builder.directory().getAbsolutePath(); + environment = new LinkedHashMap<>(builder.environment()); + + encoding = context.encoding().name(); + inheritStdin = context.listener() == null; + postExitCommand = context.postExitCommand(); + relaunchAlways = context.relaunchPolicy() == MonitorClient.RelaunchPolicy.ALWAYS; + relaunchOnCrash = context.relaunchPolicy() == MonitorClient.RelaunchPolicy.ON_CRASH; + gameDirectoryId = context.gameDirectoryId(); + + launchOptions = context.options(); + } } diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ResultSpec.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ResultSpec.java index 94d1c65bf63..42603a7209b 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ResultSpec.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ResultSpec.java @@ -17,9 +17,12 @@ */ package org.jackhuang.hmcl.monitor; +import org.jackhuang.hmcl.game.LaunchOptions; +import org.jackhuang.hmcl.launch.ProcessListener; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; +import java.nio.file.Path; import java.util.List; /// The result of a supervised game process, written by the monitor and consumed by a relaunched @@ -34,23 +37,21 @@ public final class ResultSpec { @Nullable String exitType; /// The session log file collecting the game output, or `null` when unavailable. @Nullable String logFile; - /// The id of the launched instance, or `null` when unknown. - @Nullable String instanceId; /// The persistent id of the registered game directory owning the launched instance, or `null` /// when unknown. @Nullable String gameDirectoryId; - /// The display name of the launched version, or `null` when unknown. - @Nullable String versionName; - /// The game directory of the launched instance, or `null` when unknown. - @Nullable String gameDir; - /// The maximum memory of the game process in MiB, or `null` when unset. - @Nullable Integer maxMemory; - /// The binary path of the Java runtime the game runs on, or `null` when unknown. - @Nullable String javaBinary; - /// The version string of the Java runtime the game runs on, or `null` when unknown. - @Nullable String javaVersion; - /// The architecture name of the Java runtime the game runs on, or `null` when unknown. - @Nullable String javaArchitecture; /// The command line of the game process, or `null` when unknown. @Nullable List commands; + + @Nullable LaunchOptions launchOptions; + + ResultSpec(ProcessSpec spec, long pid, long processStartTime, ProcessListener.ExitType exitType, Path logFile) { + this.pid = pid; + this.processStartTime = processStartTime; + this.exitType = exitType.name(); + this.logFile = logFile.toString(); + gameDirectoryId = spec.gameDirectoryId; + commands = spec.command; + launchOptions = spec.launchOptions; + } } From ac1583d7ac2c3209386670a4ff169185a317b9ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B1=BC=E5=89=8D=E4=B8=89=E7=99=BE?= Date: Mon, 12 Oct 2026 18:07:30 +0800 Subject: [PATCH 21/44] =?UTF-8?q?docs:=20=E4=BC=98=E5=8C=96=E6=B3=A8?= =?UTF-8?q?=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/util/platform/ProcessInfo.java | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ProcessInfo.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ProcessInfo.java index 9fe8fd1edb8..75c623d0b1b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ProcessInfo.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ProcessInfo.java @@ -22,32 +22,31 @@ import java.util.List; -/// The information about a game process that process listeners and UIs can rely on, regardless of +/// The information about a process that process listeners and UIs can rely on, regardless of /// whether the process is owned by this JVM ([ManagedProcess]) or supervised by another process, /// e.g. the HMCL monitor. @NotNullByDefault public interface ProcessInfo { - /// The process id of the game process. + /// The process id of the process. long getPid(); - /// The handle of the game process, or `null` if it cannot be resolved anymore, e.g. a dead + /// The handle of the process, or `null` if it cannot be resolved anymore, e.g. a dead /// process whose id has already been reaped by the operating system. @Nullable ProcessHandle getHandle(); - /// The command line of the game process. + /// The command line of the process. List getCommands(); - /// The classpath of the game process, or `null` when unknown. + /// The classpath of the process, or `null` when unknown. @Nullable String getClasspath(); - /// The epoch millisecond at which the game process started. + /// The epoch millisecond at which the process started. long getProcessStartTime(); - /// Returns whether the game process is still running. + /// Returns whether the process is still running. boolean isRunning(); - /// Destroys the game process, in a way that lets the exit classifier recognize the exit as a - /// manual cancellation rather than a crash. + /// Destroys the process. void stop(); } From 52249ca5acfe2978020a8a1c9b900b8a8d4baee6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B1=BC=E5=89=8D=E4=B8=89=E7=99=BE?= Date: Tue, 20 Oct 2026 21:37:43 +0800 Subject: [PATCH 22/44] =?UTF-8?q?refactor:=20=E7=9B=91=E6=8E=A7=E8=BF=9B?= =?UTF-8?q?=E7=A8=8B=E5=86=85=E5=A4=8D=E7=94=A8=E5=90=AF=E5=8A=A8=E5=99=A8?= =?UTF-8?q?=20UI=20=E5=B1=95=E7=A4=BA=E5=B4=A9=E6=BA=83=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=EF=BC=8C=E6=9B=BF=E4=BB=A3=E9=87=8D=E6=96=B0=E6=8B=89=E8=B5=B7?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/jackhuang/hmcl/Launcher.java | 8 +- .../hmcl/monitor/MonitorCrashReporter.java | 107 ++-------- .../jackhuang/hmcl/monitor/MonitorClient.java | 16 +- .../hmcl/monitor/MonitorGameProcess.java | 11 +- .../hmcl/monitor/MonitorProtocol.java | 24 ++- .../hmcl/monitor/MonitorSupervisor.java | 197 ++++++------------ .../jackhuang/hmcl/monitor/ProcessSpec.java | 53 ++--- .../jackhuang/hmcl/monitor/ResultSpec.java | 43 ++-- 8 files changed, 154 insertions(+), 305 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java b/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java index cad411ff15d..4a753bd4b76 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java @@ -36,6 +36,7 @@ import javafx.util.Duration; import org.jackhuang.hmcl.monitor.MonitorCrashReporter; import org.jackhuang.hmcl.game.HMCLCacheRepository; +import org.jackhuang.hmcl.monitor.MonitorSupervisor; import org.jackhuang.hmcl.setting.*; import org.jackhuang.hmcl.task.AsyncTaskExecutor; import org.jackhuang.hmcl.task.Schedulers; @@ -147,10 +148,9 @@ public void start(Stage primaryStage) { primaryStage.show(); - // check wheather to show log from HMCL monitor - Path pendingCrashReport = MonitorCrashReporter.processArguments(getParameters().getRaw().toArray(new String[0])); - if (pendingCrashReport != null) - MonitorCrashReporter.show(pendingCrashReport); + // check whether to show log from HMCL monitor + if (MonitorSupervisor.isCrashed()) + MonitorCrashReporter.show(); }); } catch (Throwable e) { CRASH_REPORTER.uncaughtException(Thread.currentThread(), e); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java index 6f9ca053f92..26e4ca263ba 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java @@ -17,12 +17,10 @@ */ package org.jackhuang.hmcl.monitor; -import com.google.gson.JsonParseException; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.game.Log; -import org.jackhuang.hmcl.launch.ProcessListener; import org.jackhuang.hmcl.setting.GameDirectory; import org.jackhuang.hmcl.setting.GameDirectoryID; import org.jackhuang.hmcl.setting.GameDirectoryManager; @@ -31,54 +29,31 @@ import org.jackhuang.hmcl.ui.GameCrashWindow; import org.jackhuang.hmcl.util.CircularArrayList; import org.jackhuang.hmcl.util.Log4jLevel; -import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jetbrains.annotations.NotNullByDefault; -import org.jetbrains.annotations.Nullable; -import java.io.IOException; -import java.io.UncheckedIOException; import java.nio.file.Files; -import java.nio.file.Path; import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.stream.Stream; -import static javafx.application.Platform.runLater; import static org.jackhuang.hmcl.util.logging.Logger.LOG; -/// The relaunched launcher role of the monitor feature: presenting the crash window of a supervised -/// game process, reusing the regular JavaFX user interface. +/// Presents a supervised game's crash window from the result retained in the monitor JVM after +/// that JVM continues into the launcher UI. @NotNullByDefault public final class MonitorCrashReporter { private MonitorCrashReporter() { } - public static @Nullable Path processArguments(String[] args) { - if (args.length == 2 && args[0].equals("--crash-report")) - return Path.of(args[1]); - return null; - } - - public static void show(Path resultFile) { - ResultSpec result; - try { - result = JsonUtils.fromJsonFile(resultFile, ResultSpec.class); - } catch (IOException | JsonParseException e) { - LOG.warning("Failed to read the monitor result file " + resultFile, e); - return; - } - try { - Files.deleteIfExists(resultFile); - } catch (IOException e) { - LOG.warning("Failed to delete the monitor result file " + resultFile, e); - } - if (result == null || result.launchOptions == null || result.gameDirectoryId == null) + public static void show() { + ResultSpec result = MonitorSupervisor.getResultSpec(); + if (result == null || result.gameDirectoryId() == null) return; // find repository - GameDirectoryID id = GameDirectoryID.parse(result.gameDirectoryId); + GameDirectoryID id = GameDirectoryID.parse(result.gameDirectoryId()); Optional match = GameDirectoryManager.getGameDirectories().stream() .filter(directory -> directory.getId().equals(id)) .findFirst(); @@ -87,86 +62,50 @@ public static void show(Path resultFile) { GameDirectoryManager.setSelectedGameDirectory(match.get()); HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository(); - // find instance - GameInstanceID instanceId = result.launchOptions.getInstanceId(); + GameInstanceID instanceId = result.launchOptions().getInstanceId(); HMCLGameInstance instance = repository.findInstance(instanceId); - // instance found - if (instance != null) { + if (instance != null) { // instance found presentCrashWindow(result, instance); return; } - // repository found but cannot find instance - if (repository.isLoaded()) { - logFail(instanceId, result.logFile, repository); - return; - } - LOG.info("Waiting for the game repository to load before showing the crash window"); - repository.refreshAsync().whenComplete(Schedulers.defaultScheduler(), exception -> { - if (exception == null) { - runLater(() -> { - HMCLGameInstance loaded = repository.findInstance(instanceId); - if (loaded != null) - presentCrashWindow(result, loaded); - else - logFail(instanceId, result.logFile, repository); - }); - } else { - LOG.warning("Failed to load the game repository while waiting to show the crash window", exception); - logFail(instanceId, result.logFile, repository); - } + repository.refreshAsync().thenRunAsync(Schedulers.javafx(), () -> { + HMCLGameInstance loaded = repository.findInstance(instanceId); + if (loaded != null) + presentCrashWindow(result, loaded); + else // TODO 把这里的日志改短一点 + LOG.warning("Cannot resolve the launched instance " + instanceId + " (" + + repository.getSnapshot().getInstanceCount() + " instances, loaded=" + + repository.isLoaded() + ", base directory " + + repository.getLayout().getBaseDirectory() + + "), skip showing the crash window." + + " The session log is at " + result.logFile()); }).start(); } - private static void logFail(GameInstanceID instanceId, @Nullable String logFile, - @Nullable HMCLGameRepository repository) { - LOG.warning("Cannot resolve the launched instance " + instanceId + " (" - + (repository != null - ? repository.getSnapshot().getInstanceCount() + " instances, loaded=" - + repository.isLoaded() + ", base directory " - + repository.getLayout().getBaseDirectory() - : "no registered game directory has the recorded id") - + "), skip showing the crash window." - + " The session log is at " + logFile); - } - private static void presentCrashWindow(ResultSpec result, HMCLGameInstance instance) { - MonitorGameProcess process = new MonitorGameProcess( - ProcessHandle.of(result.pid).orElse(null), - Objects.requireNonNullElseGet(result.commands, List::of), - result.processStartTime, - null); // pretend this is the real process. - - @Nullable Path logFile = result.logFile != null ? Path.of(result.logFile) : null; - - ProcessListener.ExitType exitType = ProcessListener.ExitType.valueOf(result.exitType); Task.supplyAsync(() -> { - if (logFile == null) - return List.of(); - // limit the lines of log int limit = Log.getLogLines(); CircularArrayList tail = new CircularArrayList<>(limit); - try (Stream lines = Files.lines(logFile, MonitorProtocol.CHARSET)) { + try (Stream lines = Files.lines(result.logFile(), MonitorProtocol.CHARSET)) { lines.forEach(line -> { if (tail.size() == limit) tail.removeFirst(); tail.addLast(line); }); - } catch (IOException | UncheckedIOException e) { - LOG.warning("Failed to read the session log file " + logFile, e); } - Files.deleteIfExists(logFile); + Files.deleteIfExists(result.logFile()); return tail.stream() .map(line -> new Log(line, Log4jLevel.guessLevel(line))) .toList(); }).whenComplete(Schedulers.javafx(), (logs, exception) -> { if (exception != null) - LOG.warning("Failed to read the session log file " + result.logFile, exception); - new GameCrashWindow(process, exitType, instance, Objects.requireNonNull(result.launchOptions), + LOG.warning("Failed to read the session log file " + result.logFile(), exception); + new GameCrashWindow(result.process(), result.exitType(), instance, Objects.requireNonNull(result.launchOptions()), exception == null ? logs : List.of()).show(); }).start(); } diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java index 959c53342c0..0f191e75ed9 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java @@ -77,11 +77,11 @@ public record MonitorLaunchContext( RelaunchPolicy relaunchPolicy) {} public enum RelaunchPolicy { - /// Never relaunch the launcher after the game exits. + /// Never continue into the launcher UI after the game exits. NEVER, - /// Relaunch the launcher when the game exits abnormally. + /// Continue into the launcher UI after a non-normal exit if the protocol stream has failed. ON_CRASH, - /// Relaunch the launcher unconditionally after the game exits. + /// Continue into the launcher UI after any exit if the protocol stream has failed. ALWAYS } @@ -108,19 +108,15 @@ public boolean shouldSkipClass(Class clazz) { }) .create(); - static void writeSpec(Object obj, Path specFile) throws IOException { - try (var writer = Files.newBufferedWriter(specFile)) { - gsonForGameOptions.toJson(obj, writer); - } - } - public static MonitorGameProcess launchNewMonitor(MonitorLaunchContext context) throws IOException { Path thisJar = JarUtils.thisJarPath(); if (thisJar == null) throw new IOException("Failed to find current HMCL location"); Path specFile = Files.createTempFile("hmcl-monitor-spec-", ".json"); - writeSpec(new ProcessSpec(context), specFile); + try (var writer = Files.newBufferedWriter(specFile)) { + gsonForGameOptions.toJson(new ProcessSpec(context), writer); + } List commandline = JavaProcessLauncher.buildJavaCommandLine(thisJar, "--monitor", specFile.toAbsolutePath().toString()); diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java index 64c51eef307..68c33185a87 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java @@ -17,12 +17,12 @@ */ package org.jackhuang.hmcl.monitor; -import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.platform.ProcessInfo; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.List; @@ -30,10 +30,9 @@ /// A view of the game process supervised by the HMCL monitor. /// -///

Unlike [org.jackhuang.hmcl.util.platform.ManagedProcess], the game process is not spawned nor -/// owned by this JVM; only the information reported by the monitor's handshake is known. Instances -/// may also represent already-exited games when presenting the crash window of a relaunched -/// launcher, in which case the [handle][#getHandle] may be unresolvable and the spec file unknown. +/// Unlike [org.jackhuang.hmcl.util.platform.ManagedProcess], the game process is not spawned nor +/// owned by this JVM. Its handle is resolved from the monitor's handshake and may be unavailable +/// if the game has already exited. The original command line is supplied by the launch context. @NotNullByDefault public final class MonitorGameProcess implements ProcessInfo { @@ -95,7 +94,7 @@ public void stop() { if (specFile != null) try { // notify the monitor - JsonUtils.writeToJsonFile(specFile, ProcessSpec.canceledSpec()); + Files.writeString(specFile, "CANCELED"); } catch (IOException e) { LOG.warning("Failed to rewrite the monitor spec file " + specFile + " to mark the cancellation", e); } diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java index 9e43c57cd38..53f324b2fbd 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java @@ -20,6 +20,9 @@ import org.jackhuang.hmcl.launch.ProcessListener; import org.jetbrains.annotations.NotNullByDefault; +import java.io.FileDescriptor; +import java.io.FileOutputStream; +import java.io.PrintStream; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; @@ -40,6 +43,9 @@ public final class MonitorProtocol { /// How long to wait for the monitor's handshake before giving up, in milliseconds. static final long HANDSHAKE_TIMEOUT = 20_000; + /// The shared protocol output on raw stderr, also used to detect failures when writing messages. + static final PrintStream protocol = new PrintStream(new FileOutputStream(FileDescriptor.err), true, MonitorProtocol.CHARSET); + /// Message tag of the handshake message. static final String TAG_PID = "P"; /// Message tag of a game output line. @@ -50,18 +56,18 @@ public final class MonitorProtocol { private MonitorProtocol() { } - /// Builds the handshake message carrying the game process id and its start time. - static String pidMessage(long pid, long processStartTime) { - return TAG_PID + "\t" + pid + "\t" + processStartTime; + /// Writes and flushes the handshake message carrying the game process id and its start time. + static void pidMessage(long pid, long processStartTime) { + protocol.println(TAG_PID + "\t" + pid + "\t" + processStartTime); } - /// Builds a game output line message. - static String logMessage(boolean isErrorStream, String line) { - return TAG_LOG + "\t" + (isErrorStream ? 1 : 0) + "\t" + line; + /// Writes and flushes a game output line message. + static void logMessage(boolean isErrorStream, String line) { + protocol.println(TAG_LOG + "\t" + (isErrorStream ? 1 : 0) + "\t" + line); } - /// Builds the game exit message carrying the exit code and its classified type. - static String exitMessage(int exitCode, ProcessListener.ExitType exitType) { - return TAG_EXIT + "\t" + exitCode + "\t" + exitType.name(); + /// Writes and flushes the game exit message carrying the exit code and its classified type. + static void exitMessage(int exitCode, ProcessListener.ExitType exitType) { + protocol.println(TAG_EXIT + "\t" + exitCode + "\t" + exitType.name()); } } diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java index 9c1bf25fe94..c1d31927c88 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java @@ -23,8 +23,6 @@ import org.jackhuang.hmcl.launch.StreamPump; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.gson.JsonUtils; -import org.jackhuang.hmcl.util.io.JarUtils; -import org.jackhuang.hmcl.util.platform.JavaProcessLauncher; import org.jackhuang.hmcl.util.platform.ManagedProcess; import org.jackhuang.hmcl.util.platform.SystemUtils; import org.jetbrains.annotations.NotNullByDefault; @@ -34,7 +32,6 @@ import java.nio.charset.Charset; import java.nio.file.Files; import java.nio.file.Path; -import java.time.Instant; import java.util.List; import java.util.Objects; import java.util.concurrent.atomic.AtomicBoolean; @@ -45,36 +42,42 @@ /// The monitor process role of the monitor feature: creating the game process, supervising it until /// it exits, and reporting the result. /// -///

The monitor has no user interface. It owns the game process, so it always knows the real exit -/// code even after the main launcher process has exited. Game output is teed into a session log -/// file and forwarded to the main launcher process over [the protocol][MonitorProtocol]; after the -/// game exits, the post-exit command is run, and HMCL is relaunched if the launch options ask for -/// it and the main launcher process is already gone. The crash result file and the session log are -/// only kept when a relaunched launcher is going to consume them. +/// The monitor owns the game process and collects its output in a session log while forwarding it +/// to the main launcher over [the protocol][MonitorProtocol]. After the game exits, it runs the +/// post-exit command and stores the result in memory. When the protocol stream has failed and the +/// relaunch policy requests it, the entry point continues into the launcher UI in this same JVM. +/// No crash result file is written; the crash window reads the stored result and session log. @NotNullByDefault public final class MonitorSupervisor { + private static boolean shouldRestart = false; + private static boolean crashed = false; + private static @Nullable ResultSpec resultSpec; private MonitorSupervisor() { } + public static @Nullable ResultSpec getResultSpec() { + return resultSpec; + } + + public static boolean isCrashed() { + return crashed; + } + /// Handles the `--monitor` entry point. /// /// @param args the process arguments - /// @return whether the arguments were consumed; the caller should exit afterward, since - /// [MonitorSupervisor#start] blocks until the game process has exited + /// @return `true` when monitor mode has finished and the caller should exit; `false` for + /// non-monitor arguments or when this JVM should continue into the launcher UI public static boolean processArguments(String[] args) { - if (args.length < 1 || !args[0].equals("--monitor")) + if (args.length != 2 || !args[0].equals("--monitor")) return false; - if (args.length != 2) { - LOG.error("Usage: --monitor "); - return true; - } try { start(Path.of(args[1])); } catch (IOException | InterruptedException e) { LOG.error("Failed to supervise the game process", e); } - return true; + return !shouldRestart; } /// Reads the [ProcessSpec] from `specPath`, creates the process it describes, supervises it @@ -85,52 +88,38 @@ public static boolean processArguments(String[] args) { /// @throws InterruptedException if interrupted while waiting for the game process public static void start(Path specPath) throws IOException, InterruptedException { LOG.info("*** HMCL MONITOR ***"); - - // stdout carries the monitor's log output, so the protocol goes over raw stderr - PrintStream protocol = new PrintStream(new FileOutputStream(FileDescriptor.err), true, MonitorProtocol.CHARSET); - ProcessSpec spec; try { spec = JsonUtils.fromJsonFile(specPath, ProcessSpec.class); } catch (JsonParseException e) { throw new IOException("Malformed process spec file " + specPath, e); } - if (spec == null || spec.command == null || spec.command.isEmpty() || spec.encoding == null) + if (spec == null || spec.command == null || spec.command.isEmpty()) throw new IOException("Malformed process spec file " + specPath); - Files.delete(specPath); + Files.deleteIfExists(specPath); - ProcessBuilder pb = new ProcessBuilder(spec.command); - @Nullable String directory = spec.directory; - if (directory != null && !directory.isEmpty()) - pb.directory(new File(directory)); - if (spec.environment != null) - pb.environment().putAll(spec.environment); + ProcessBuilder pb = new ProcessBuilder(spec.command).directory(new File(spec.directory)); + pb.environment().putAll(spec.environment); if (spec.inheritStdin) pb.redirectInput(ProcessBuilder.Redirect.INHERIT); + ManagedProcess gameProcess = new ManagedProcess(pb.start(), spec.command); - long fallbackStartTime = System.currentTimeMillis(); - Process game = pb.start(); - long processStartTime = game.info().startInstant().map(Instant::toEpochMilli).orElse(fallbackStartTime); - - LOG.info("Game process created, pid " + game.pid() + ", spec " + specPath); - protocol.println(MonitorProtocol.pidMessage(game.pid(), processStartTime)); + LOG.info("Game process created, pid " + gameProcess.getPid() + ", spec " + specPath); + MonitorProtocol.pidMessage(gameProcess.getPid(), gameProcess.getProcessStartTime()); Path logFile = Files.createTempFile("hmcl-monitor-game-", ".log"); - // The monitor owns the game process, so the regular [ManagedProcess] applies, and the exit - // classification is performed by the very same [ExitWaiter] the direct launch path uses. - ManagedProcess gameProcess = new ManagedProcess(game, spec.command); // Forward one game output line to the log file, the managed process (feeding the exit // classification) and the protocol stream. A broken protocol stream (the launcher exited // early) only stops forwarding; the log file keeps collecting the remaining output. try (BufferedWriter logWriter = Files.newBufferedWriter(logFile, MonitorProtocol.CHARSET)) { - startMonitors(gameProcess, logWriter, protocol, spec, processStartTime, logFile, specPath); + startMonitors(gameProcess, logWriter, spec, logFile, specPath); } } - private static void startMonitors(ManagedProcess gameProcess, BufferedWriter logWriter, PrintStream protocol, - ProcessSpec spec, long processStartTime, Path logFile, Path specPath) throws InterruptedException { + private static void startMonitors(ManagedProcess gameProcess, BufferedWriter logWriter, + ProcessSpec spec, Path logFile, Path specPath) throws InterruptedException { AtomicBoolean parentGone = new AtomicBoolean(false); BiConsumer sink = (line, isErrorStream) -> { try { @@ -144,8 +133,8 @@ private static void startMonitors(ManagedProcess gameProcess, BufferedWriter log } gameProcess.addLine(line); if (!parentGone.get()) { - protocol.println(MonitorProtocol.logMessage(isErrorStream, line)); - if (protocol.checkError()) + MonitorProtocol.logMessage(isErrorStream, line); + if (MonitorProtocol.protocol.checkError()) parentGone.set(true); } }; @@ -158,107 +147,51 @@ private static void startMonitors(ManagedProcess gameProcess, BufferedWriter log gameProcess.addRelatedThread(stdoutPump); gameProcess.addRelatedThread(stderrPump); - Thread exitWaiter = Lang.thread(new ExitWaiter(gameProcess, List.of(stdoutPump, stderrPump), (exitCode, exitType) -> { + Lang.thread(new ExitWaiter(gameProcess, List.of(stdoutPump, stderrPump), (exitCode, exitType) -> { LOG.info("Game exited with code " + exitCode + "(" + Integer.toHexString(exitCode) + "), type " + exitType); - - // A present spec file can only mean the main launcher marked a manual cancellation - // right before destroying the game process (see MonitorGameProcess#stop); report the - // exit as interrupted like the direct launch path does. - boolean canceled = readCanceled(specPath); - ProcessListener.ExitType reportedType = canceled ? ProcessListener.ExitType.INTERRUPTED : exitType; - if (canceled) - LOG.info("The game process was canceled by the main launcher, reporting the exit as interrupted"); - protocol.println(MonitorProtocol.exitMessage(exitCode, reportedType)); - - runPostExitCommand(spec); - - boolean crashed = reportedType != ProcessListener.ExitType.NORMAL; - try { - boolean relaunch = !canceled && (spec.relaunchAlways || (spec.relaunchOnCrash && crashed)); - - if (protocol.checkError()) - parentGone.set(true); - - @Nullable Path resultFile = null; - if (relaunch && parentGone.get()) { - if (crashed) { - resultFile = Files.createTempFile("hmcl-monitor-result-", ".json"); - ResultSpec result = new ResultSpec(spec, gameProcess.getPid(), processStartTime, exitType, logFile); - MonitorClient.writeSpec(result, resultFile); - } - relaunch(resultFile); + ProcessListener.ExitType reportedType = readCanceled(specPath) ? ProcessListener.ExitType.INTERRUPTED : exitType; + crashed = reportedType != ProcessListener.ExitType.NORMAL; + + if (MonitorProtocol.protocol.checkError()) + parentGone.set(true); + + shouldRestart = parentGone.get() && switch (spec.relaunchPolicy) { + case ALWAYS -> true; + case ON_CRASH -> crashed; + case NEVER -> false; + }; + + MonitorProtocol.exitMessage(exitCode, reportedType); + + if (spec.postExitCommand != null && !spec.postExitCommand.isEmpty()) { + try { + ProcessBuilder builder = new ProcessBuilder(spec.postExitCommand).directory(new File(spec.directory)); + builder.environment().putAll(spec.environment); + SystemUtils.callExternalProcess(builder); + } catch (Throwable e) { + LOG.warning("An Exception happened while running exit command.", e); } - - // The session log is only consumed through a crash result file; deleting it in - // every other case keeps it from accumulating in the temporary directory. - if (resultFile == null) { - try { - Files.deleteIfExists(logFile); - } catch (IOException e) { - LOG.warning("Failed to delete the session log file " + logFile, e); - } - } - } catch (IOException e) { - LOG.error("Failed to write the monitor result file or relaunch the launcher", e); } - }), "exit-waiter", true); - - // Keep the monitor process alive until the game has exited and been reported; the caller - // exits the process right after this method returns. - exitWaiter.join(); - } - private static void runPostExitCommand(ProcessSpec spec) { - if (spec.postExitCommand == null || spec.postExitCommand.isEmpty()) - return; - try { - // The direct launch path runs the post-exit command in the game directory, which is - // not necessarily the game process's working directory when version isolation is on. - ProcessBuilder builder = new ProcessBuilder(spec.postExitCommand); - if (spec.launchOptions != null) - builder.directory(spec.launchOptions.getGameDir().toFile()); - else if (spec.directory != null && !spec.directory.isEmpty()) - builder.directory(new File(spec.directory)); - if (spec.environment != null) - builder.environment().putAll(spec.environment); - SystemUtils.callExternalProcess(builder); - } catch (Throwable e) { - LOG.warning("An Exception happened while running exit command.", e); - } + resultSpec = new ResultSpec(spec, gameProcess, reportedType, logFile); + }), "exit-waiter", true).join(); } /// Returns whether the main launcher rewrote the spec file to mark the game process as manually - /// canceled, consuming the rewritten file so that it does not outlive this check. + /// canceled. A successfully read file is deleted before checking for the `CANCELED` prefix. + /// + /// [MonitorGameProcess#stop] writes the marker before requesting process termination. The + /// caller reports a recognized cancellation as [ProcessListener.ExitType#INTERRUPTED]. private static boolean readCanceled(Path specPath) { if (!Files.exists(specPath)) return false; - boolean canceled = false; try { - ProcessSpec spec = JsonUtils.fromJsonFile(specPath, ProcessSpec.class); - canceled = spec != null && spec.canceled; + String canceled = Files.readString(specPath); + Files.deleteIfExists(specPath); + return canceled.startsWith("CANCELED"); } catch (IOException e) { - LOG.warning("Failed to read the rewritten monitor spec file " + specPath, e); - } finally { - try { - Files.deleteIfExists(specPath); - } catch (IOException e) { - LOG.warning("Failed to delete the rewritten monitor spec file " + specPath, e); - } - } - return canceled; - } - - /// Starts a new HMCL main process. Fire-and-forget; the current monitor process exits right after. - private static void relaunch(@Nullable Path resultFile) throws IOException { - Path thisJar = JarUtils.thisJarPath(); - if (thisJar == null) { - LOG.warning("Failed to find the current HMCL jar, cannot relaunch the launcher"); - return; - } - if (resultFile != null) { // crashed - JavaProcessLauncher.startJava(thisJar, "--crash-report", resultFile.toAbsolutePath().toString()); - } else { - JavaProcessLauncher.startJava(thisJar); + LOG.warning("Failed to check whether to cancel " + specPath, e); + return false; } } } diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java index 85238b27119..b8d5ee0e651 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java @@ -22,60 +22,47 @@ import org.jetbrains.annotations.Nullable; import java.util.ArrayList; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /// The specification of the game process to supervise, exchanged with the monitor process as JSON. @NotNullByDefault -final class ProcessSpec { +public final class ProcessSpec { /// The command line to execute, passed to [ProcessBuilder] verbatim. - @Nullable List command; - /// The working directory of the process, or `null` to inherit the monitor's working directory. - @Nullable String directory; - /// Environment variables overlaid on the monitor's environment, or `null` to keep it unchanged. - @Nullable Map environment; - /// The charset of the game process output; required. - @Nullable String encoding; + public final @Nullable List command; + /// The absolute working directory used for the game process and the post-exit command. + public final String directory; + /// Environment variables overlaid on the monitor's environment for both commands. + public final Map environment; + /// The name of the charset used to decode the game process output. + public final String encoding; /// Whether the game process should inherit the monitor's standard input; set when no process /// listener is present. - boolean inheritStdin; + public final boolean inheritStdin; /// The post-exit command to run after the game exits, already tokenized, or `null`. - @Nullable List postExitCommand; - /// Whether the main launcher manually canceled the game process; the monitor reports such an - /// exit as interrupted. - boolean canceled; - /// Whether to relaunch the launcher after the game exits unconditionally. - boolean relaunchAlways; - /// Whether to relaunch the launcher after the game exits abnormally. - boolean relaunchOnCrash; + public final @Nullable List postExitCommand; + + /// The policy for opening the launcher UI in the monitor JVM after the protocol stream fails. + public final MonitorClient.RelaunchPolicy relaunchPolicy; /// The persistent id of the registered game directory owning the launched instance, or `null` /// when unknown. - @Nullable String gameDirectoryId; - - @Nullable LaunchOptions launchOptions; - - public static ProcessSpec canceledSpec() { - ProcessSpec spec = new ProcessSpec(); - spec.canceled = true; - return spec; - } + public final @Nullable String gameDirectoryId; - public ProcessSpec() {} + /// The launch options retained for presenting the game crash window. + public final LaunchOptions launchOptions; + /// Creates a process specification from a launch context whose builder has a working directory. public ProcessSpec(MonitorClient.MonitorLaunchContext context) { ProcessBuilder builder = context.builder(); command = new ArrayList<>(builder.command()); - if (builder.directory() != null) - directory = builder.directory().getAbsolutePath(); - environment = new LinkedHashMap<>(builder.environment()); + directory = builder.directory().getAbsolutePath(); + environment = builder.environment(); encoding = context.encoding().name(); inheritStdin = context.listener() == null; postExitCommand = context.postExitCommand(); - relaunchAlways = context.relaunchPolicy() == MonitorClient.RelaunchPolicy.ALWAYS; - relaunchOnCrash = context.relaunchPolicy() == MonitorClient.RelaunchPolicy.ON_CRASH; + relaunchPolicy = context.relaunchPolicy(); gameDirectoryId = context.gameDirectoryId(); launchOptions = context.options(); diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ResultSpec.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ResultSpec.java index 42603a7209b..6830f9ac99f 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ResultSpec.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ResultSpec.java @@ -19,39 +19,28 @@ import org.jackhuang.hmcl.game.LaunchOptions; import org.jackhuang.hmcl.launch.ProcessListener; +import org.jackhuang.hmcl.util.platform.ManagedProcess; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; import java.nio.file.Path; import java.util.List; -/// The result of a supervised game process, written by the monitor and consumed by a relaunched -/// launcher process through the `--crash-report` argument. +/// The in-memory result of a supervised game process, retained when the monitor JVM continues +/// into the launcher UI to present the game crash window. +/// +/// @param process The supervised game process, which has already exited. +/// @param exitType The reported exit type, including `INTERRUPTED` for a recognized cancellation. +/// @param logFile The session log file containing the game output. +/// @param gameDirectoryId The persistent id of the registered game directory owning the launched +/// instance, or `null` when unknown. +/// @param commands The command line of the game process, or `null` when unknown. +/// @param launchOptions The launch options used to identify the instance and present its crash window. @NotNullByDefault -public final class ResultSpec { - /// The process id of the game process; `-1` when unknown. - long pid; - /// The epoch millisecond at which the game process started. - long processStartTime; - /// The name of the [ProcessListener.ExitType] of the game process. - @Nullable String exitType; - /// The session log file collecting the game output, or `null` when unavailable. - @Nullable String logFile; - /// The persistent id of the registered game directory owning the launched instance, or `null` - /// when unknown. - @Nullable String gameDirectoryId; - /// The command line of the game process, or `null` when unknown. - @Nullable List commands; - - @Nullable LaunchOptions launchOptions; - - ResultSpec(ProcessSpec spec, long pid, long processStartTime, ProcessListener.ExitType exitType, Path logFile) { - this.pid = pid; - this.processStartTime = processStartTime; - this.exitType = exitType.name(); - this.logFile = logFile.toString(); - gameDirectoryId = spec.gameDirectoryId; - commands = spec.command; - launchOptions = spec.launchOptions; +public record ResultSpec(ManagedProcess process, ProcessListener.ExitType exitType, Path logFile, + @Nullable String gameDirectoryId, @Nullable List commands, LaunchOptions launchOptions) { + /// Combines the launch specification with the supervised process and its reported exit result. + public ResultSpec(ProcessSpec spec, ManagedProcess process, ProcessListener.ExitType exitType, Path logFile) { + this(process, exitType, logFile, spec.gameDirectoryId, spec.command, spec.launchOptions); } } From 5b8d22b2c81df01051864ffbbf28d37dabc416bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B1=BC=E5=89=8D=E4=B8=89=E7=99=BE?= Date: Tue, 20 Oct 2026 21:52:44 +0800 Subject: [PATCH 23/44] =?UTF-8?q?refactor:=20=E5=B0=81=E8=A3=85=20MonitorP?= =?UTF-8?q?rotocol=20=E5=8D=8F=E8=AE=AE=E6=B5=81=E5=B9=B6=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E7=88=B6=E8=BF=9B=E7=A8=8B=E5=AD=98=E6=B4=BB=E6=A3=80?= =?UTF-8?q?=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/jackhuang/hmcl/monitor/MonitorProtocol.java | 6 +++++- .../jackhuang/hmcl/monitor/MonitorSupervisor.java | 13 ++----------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java index 53f324b2fbd..8e29d1e63d0 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java @@ -44,7 +44,7 @@ public final class MonitorProtocol { static final long HANDSHAKE_TIMEOUT = 20_000; /// The shared protocol output on raw stderr, also used to detect failures when writing messages. - static final PrintStream protocol = new PrintStream(new FileOutputStream(FileDescriptor.err), true, MonitorProtocol.CHARSET); + static private final PrintStream protocol = new PrintStream(new FileOutputStream(FileDescriptor.err), true, MonitorProtocol.CHARSET); /// Message tag of the handshake message. static final String TAG_PID = "P"; @@ -70,4 +70,8 @@ static void logMessage(boolean isErrorStream, String line) { static void exitMessage(int exitCode, ProcessListener.ExitType exitType) { protocol.println(TAG_EXIT + "\t" + exitCode + "\t" + exitType.name()); } + + static boolean parentGone() { + return protocol.checkError(); + } } diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java index c1d31927c88..f0ecefac77d 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java @@ -34,7 +34,6 @@ import java.nio.file.Path; import java.util.List; import java.util.Objects; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.BiConsumer; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -120,7 +119,6 @@ public static void start(Path specPath) throws IOException, InterruptedException private static void startMonitors(ManagedProcess gameProcess, BufferedWriter logWriter, ProcessSpec spec, Path logFile, Path specPath) throws InterruptedException { - AtomicBoolean parentGone = new AtomicBoolean(false); BiConsumer sink = (line, isErrorStream) -> { try { synchronized (logWriter) { @@ -132,11 +130,7 @@ private static void startMonitors(ManagedProcess gameProcess, BufferedWriter log LOG.warning("Failed to write the session log", e); } gameProcess.addLine(line); - if (!parentGone.get()) { - MonitorProtocol.logMessage(isErrorStream, line); - if (MonitorProtocol.protocol.checkError()) - parentGone.set(true); - } + MonitorProtocol.logMessage(isErrorStream, line); }; Charset encoding = Charset.forName(Objects.requireNonNull(spec.encoding)); @@ -152,10 +146,7 @@ private static void startMonitors(ManagedProcess gameProcess, BufferedWriter log ProcessListener.ExitType reportedType = readCanceled(specPath) ? ProcessListener.ExitType.INTERRUPTED : exitType; crashed = reportedType != ProcessListener.ExitType.NORMAL; - if (MonitorProtocol.protocol.checkError()) - parentGone.set(true); - - shouldRestart = parentGone.get() && switch (spec.relaunchPolicy) { + shouldRestart = MonitorProtocol.parentGone() && switch (spec.relaunchPolicy) { case ALWAYS -> true; case ON_CRASH -> crashed; case NEVER -> false; From f8353e544243a9d72b56c6b5df873b5b364541d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B1=BC=E5=89=8D=E4=B8=89=E7=99=BE?= Date: Wed, 21 Oct 2026 10:23:45 +0800 Subject: [PATCH 24/44] =?UTF-8?q?refactor:=20=E7=A7=BB=E9=99=A4=20monitor?= =?UTF-8?q?=20=E4=BC=9A=E8=AF=9D=E6=97=A5=E5=BF=97=E4=B8=B4=E6=97=B6?= =?UTF-8?q?=E6=96=87=E4=BB=B6=EF=BC=8C=E5=B4=A9=E6=BA=83=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E8=BF=9B=E7=A8=8B=E7=BC=93=E5=86=B2=E7=9A=84?= =?UTF-8?q?=E8=BE=93=E5=87=BA=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/monitor/MonitorCrashReporter.java | 42 ++++++------------- .../hmcl/monitor/MonitorSupervisor.java | 32 ++++---------- .../jackhuang/hmcl/monitor/ResultSpec.java | 8 ++-- 3 files changed, 23 insertions(+), 59 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java index 26e4ca263ba..6af42fe2574 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java @@ -17,6 +17,7 @@ */ package org.jackhuang.hmcl.monitor; +import javafx.application.Platform; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; @@ -25,17 +26,13 @@ import org.jackhuang.hmcl.setting.GameDirectoryID; import org.jackhuang.hmcl.setting.GameDirectoryManager; import org.jackhuang.hmcl.task.Schedulers; -import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.GameCrashWindow; -import org.jackhuang.hmcl.util.CircularArrayList; import org.jackhuang.hmcl.util.Log4jLevel; import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Unmodifiable; -import java.nio.file.Files; import java.util.List; -import java.util.Objects; import java.util.Optional; -import java.util.stream.Stream; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -80,33 +77,20 @@ public static void show() { + repository.getSnapshot().getInstanceCount() + " instances, loaded=" + repository.isLoaded() + ", base directory " + repository.getLayout().getBaseDirectory() - + "), skip showing the crash window." - + " The session log is at " + result.logFile()); + + "), skip showing the crash window."); }).start(); } + /// Schedules the crash window on the JavaFX thread using the configured tail of captured console output. private static void presentCrashWindow(ResultSpec result, HMCLGameInstance instance) { - Task.supplyAsync(() -> { - // limit the lines of log - int limit = Log.getLogLines(); - CircularArrayList tail = new CircularArrayList<>(limit); - try (Stream lines = Files.lines(result.logFile(), MonitorProtocol.CHARSET)) { - lines.forEach(line -> { - if (tail.size() == limit) - tail.removeFirst(); - tail.addLast(line); - }); - } - - Files.deleteIfExists(result.logFile()); - return tail.stream() - .map(line -> new Log(line, Log4jLevel.guessLevel(line))) - .toList(); - }).whenComplete(Schedulers.javafx(), (logs, exception) -> { - if (exception != null) - LOG.warning("Failed to read the session log file " + result.logFile(), exception); - new GameCrashWindow(result.process(), result.exitType(), instance, Objects.requireNonNull(result.launchOptions()), - exception == null ? logs : List.of()).show(); - }).start(); + @Unmodifiable List logs = result.process().getLines(null); + Platform.runLater(() -> new GameCrashWindow( + result.process(), + result.exitType(), + instance, + result.launchOptions(), + logs.subList(Math.max(0, logs.size() - Log.getLogLines()), logs.size()).stream() + .map(line -> new Log(line, Log4jLevel.guessLevel(line))) + .toList()).show()); } } diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java index f0ecefac77d..51c0bcba619 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java @@ -106,29 +106,12 @@ public static void start(Path specPath) throws IOException, InterruptedException LOG.info("Game process created, pid " + gameProcess.getPid() + ", spec " + specPath); MonitorProtocol.pidMessage(gameProcess.getPid(), gameProcess.getProcessStartTime()); - Path logFile = Files.createTempFile("hmcl-monitor-game-", ".log"); - - - // Forward one game output line to the log file, the managed process (feeding the exit - // classification) and the protocol stream. A broken protocol stream (the launcher exited - // early) only stops forwarding; the log file keeps collecting the remaining output. - try (BufferedWriter logWriter = Files.newBufferedWriter(logFile, MonitorProtocol.CHARSET)) { - startMonitors(gameProcess, logWriter, spec, logFile, specPath); - } + startMonitors(gameProcess, spec, specPath); } - private static void startMonitors(ManagedProcess gameProcess, BufferedWriter logWriter, - ProcessSpec spec, Path logFile, Path specPath) throws InterruptedException { + private static void startMonitors(ManagedProcess gameProcess, ProcessSpec spec, Path specPath) + throws InterruptedException { BiConsumer sink = (line, isErrorStream) -> { - try { - synchronized (logWriter) { - logWriter.write(line); - logWriter.newLine(); - logWriter.flush(); - } - } catch (IOException e) { - LOG.warning("Failed to write the session log", e); - } gameProcess.addLine(line); MonitorProtocol.logMessage(isErrorStream, line); }; @@ -144,19 +127,18 @@ private static void startMonitors(ManagedProcess gameProcess, BufferedWriter log Lang.thread(new ExitWaiter(gameProcess, List.of(stdoutPump, stderrPump), (exitCode, exitType) -> { LOG.info("Game exited with code " + exitCode + "(" + Integer.toHexString(exitCode) + "), type " + exitType); ProcessListener.ExitType reportedType = readCanceled(specPath) ? ProcessListener.ExitType.INTERRUPTED : exitType; - crashed = reportedType != ProcessListener.ExitType.NORMAL; + MonitorProtocol.exitMessage(exitCode, reportedType); + crashed = reportedType != ProcessListener.ExitType.NORMAL; shouldRestart = MonitorProtocol.parentGone() && switch (spec.relaunchPolicy) { case ALWAYS -> true; case ON_CRASH -> crashed; case NEVER -> false; }; - MonitorProtocol.exitMessage(exitCode, reportedType); - if (spec.postExitCommand != null && !spec.postExitCommand.isEmpty()) { try { - ProcessBuilder builder = new ProcessBuilder(spec.postExitCommand).directory(new File(spec.directory)); + ProcessBuilder builder = new ProcessBuilder(spec.postExitCommand).directory(spec.launchOptions.getGameDir().toFile()); builder.environment().putAll(spec.environment); SystemUtils.callExternalProcess(builder); } catch (Throwable e) { @@ -164,7 +146,7 @@ private static void startMonitors(ManagedProcess gameProcess, BufferedWriter log } } - resultSpec = new ResultSpec(spec, gameProcess, reportedType, logFile); + resultSpec = new ResultSpec(spec, gameProcess, reportedType); }), "exit-waiter", true).join(); } diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ResultSpec.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ResultSpec.java index 6830f9ac99f..661f79ab86e 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ResultSpec.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ResultSpec.java @@ -23,7 +23,6 @@ import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; -import java.nio.file.Path; import java.util.List; /// The in-memory result of a supervised game process, retained when the monitor JVM continues @@ -31,16 +30,15 @@ /// /// @param process The supervised game process, which has already exited. /// @param exitType The reported exit type, including `INTERRUPTED` for a recognized cancellation. -/// @param logFile The session log file containing the game output. /// @param gameDirectoryId The persistent id of the registered game directory owning the launched /// instance, or `null` when unknown. /// @param commands The command line of the game process, or `null` when unknown. /// @param launchOptions The launch options used to identify the instance and present its crash window. @NotNullByDefault -public record ResultSpec(ManagedProcess process, ProcessListener.ExitType exitType, Path logFile, +public record ResultSpec(ManagedProcess process, ProcessListener.ExitType exitType, @Nullable String gameDirectoryId, @Nullable List commands, LaunchOptions launchOptions) { /// Combines the launch specification with the supervised process and its reported exit result. - public ResultSpec(ProcessSpec spec, ManagedProcess process, ProcessListener.ExitType exitType, Path logFile) { - this(process, exitType, logFile, spec.gameDirectoryId, spec.command, spec.launchOptions); + public ResultSpec(ProcessSpec spec, ManagedProcess process, ProcessListener.ExitType exitType) { + this(process, exitType, spec.gameDirectoryId, spec.command, spec.launchOptions); } } From 0f091d0b82088fdfa30fab100454309ee861bde5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B1=BC=E5=89=8D=E4=B8=89=E7=99=BE?= Date: Wed, 21 Oct 2026 10:34:38 +0800 Subject: [PATCH 25/44] =?UTF-8?q?refactor:=20=E5=A4=8D=E7=94=A8=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=20Gson=20=E6=9E=84=E5=BB=BA=E5=99=A8=E5=B9=B6?= =?UTF-8?q?=E5=B0=86=20Path=20=E6=B3=A8=E5=86=8C=E4=B8=BA=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E5=B1=82=E7=BA=A7=E9=80=82=E9=85=8D=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/jackhuang/hmcl/util/gson/JsonUtils.java | 11 ++++++----- .../org/jackhuang/hmcl/monitor/MonitorClient.java | 14 +------------- 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/gson/JsonUtils.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/gson/JsonUtils.java index 3e6d66b625e..b705edd51ac 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/gson/JsonUtils.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/gson/JsonUtils.java @@ -43,7 +43,7 @@ /// - [GSON] — pretty-printing instance with all standard HMCL type adapters registered. /// - [UGLY_GSON] — compact (non-pretty) instance with the same adapter set minus /// complex-map-key serialization and the built-in type adapters for [java.time.Instant], -/// [java.util.UUID] and [java.nio.file.Path]. +/// [java.util.UUID] and the [java.nio.file.Path] hierarchy. /// /// All `fromJson` / `fromJsonFile` / `fromJsonFully` overloads return `null` when the /// JSON literal `null` is encountered. The `fromNonNull*` variants throw @@ -61,13 +61,14 @@ public final class JsonUtils { /// Configured with: /// - Pretty printing enabled. /// - Complex map key serialization enabled. - /// - Type adapters for [java.time.Instant], [java.util.UUID], and [java.nio.file.Path]. + /// - Type adapters for [java.time.Instant] and [java.util.UUID]. + /// - A type-hierarchy adapter for [java.nio.file.Path] and its subtypes. /// - [ValidationTypeAdapterFactory], [LowerCaseEnumTypeAdapter#FACTORY], and /// [JsonTypeAdapterFactory]. public static final Gson GSON = defaultGsonBuilder().create(); /// A compact [Gson] instance without pretty printing and without the extra - /// type adapters for [java.time.Instant], [java.util.UUID], and [java.nio.file.Path]. + /// type adapters for [java.time.Instant], [java.util.UUID], and the [java.nio.file.Path] hierarchy. /// /// Configured with: /// - [JsonTypeAdapterFactory] @@ -650,7 +651,7 @@ public static void writeToJsonFile(Path file, @Nullable Object value) throws IOE /// - Pretty printing enabled. /// - [InstantTypeAdapter] registered for [java.time.Instant]. /// - [UUIDTypeAdapter] registered for [java.util.UUID]. - /// - [PathTypeAdapter] registered for [java.nio.file.Path]. + /// - [PathTypeAdapter] registered as a type-hierarchy adapter for [java.nio.file.Path] and its subtypes. /// - [ValidationTypeAdapterFactory], [LowerCaseEnumTypeAdapter#FACTORY], and /// [JsonTypeAdapterFactory] registered as type adapter factories. /// @@ -664,7 +665,7 @@ public static GsonBuilder defaultGsonBuilder() { .setPrettyPrinting() .registerTypeAdapter(Instant.class, InstantTypeAdapter.INSTANCE) .registerTypeAdapter(UUID.class, UUIDTypeAdapter.INSTANCE) - .registerTypeAdapter(Path.class, PathTypeAdapter.INSTANCE) + .registerTypeHierarchyAdapter(Path.class, PathTypeAdapter.INSTANCE) .registerTypeAdapterFactory(ValidationTypeAdapterFactory.INSTANCE) .registerTypeAdapterFactory(LowerCaseEnumTypeAdapter.FACTORY) .registerTypeAdapterFactory(JsonTypeAdapterFactory.INSTANCE); diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java index 0f191e75ed9..cbcc1d4fe3d 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java @@ -20,7 +20,6 @@ import com.google.gson.ExclusionStrategy; import com.google.gson.FieldAttributes; import com.google.gson.Gson; -import com.google.gson.GsonBuilder; import org.jackhuang.hmcl.event.EventBus; import org.jackhuang.hmcl.event.JVMLaunchFailedEvent; import org.jackhuang.hmcl.event.ProcessExitedAbnormallyEvent; @@ -43,9 +42,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.time.Instant; import java.util.List; -import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; @@ -57,7 +54,6 @@ /// freshly spawned monitor process and consuming its protocol stream. @NotNullByDefault public final class MonitorClient { - private static final boolean MONITOR_ENABLED = System.getenv("HMCL_USE_HMCLMONITOR") != null; private MonitorClient() { @@ -85,15 +81,7 @@ public enum RelaunchPolicy { ALWAYS } - private static final Gson gsonForGameOptions = new GsonBuilder() - .enableComplexMapKeySerialization() - .setPrettyPrinting() - .registerTypeAdapter(Instant.class, InstantTypeAdapter.INSTANCE) - .registerTypeAdapter(UUID.class, UUIDTypeAdapter.INSTANCE) - .registerTypeHierarchyAdapter(Path.class, PathTypeAdapter.INSTANCE) // fix Path - .registerTypeAdapterFactory(ValidationTypeAdapterFactory.INSTANCE) - .registerTypeAdapterFactory(LowerCaseEnumTypeAdapter.FACTORY) - .registerTypeAdapterFactory(JsonTypeAdapterFactory.INSTANCE) + private static final Gson gsonForGameOptions = JsonUtils.defaultGsonBuilder() .addSerializationExclusionStrategy(new ExclusionStrategy() { @Override public boolean shouldSkipField(FieldAttributes f) { From 75de4728f5e84babb42e93d49ee8ef00b48ff540 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B1=BC=E5=89=8D=E4=B8=89=E7=99=BE?= Date: Wed, 21 Oct 2026 11:00:14 +0800 Subject: [PATCH 26/44] =?UTF-8?q?refactor:=20=E7=B2=BE=E7=AE=80=20MonitorS?= =?UTF-8?q?upervisor=20=E5=90=AF=E5=8A=A8=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jackhuang/hmcl/monitor/MonitorClient.java | 3 +- .../hmcl/monitor/MonitorSupervisor.java | 65 +++++-------------- .../jackhuang/hmcl/monitor/ProcessSpec.java | 2 +- 3 files changed, 20 insertions(+), 50 deletions(-) diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java index cbcc1d4fe3d..7d20964a3f2 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java @@ -93,8 +93,7 @@ public boolean shouldSkipClass(Class clazz) { return ProxyOption.class.isAssignableFrom(clazz) || Renderer.class.isAssignableFrom(clazz); // ignoring these } - }) - .create(); + }).create(); public static MonitorGameProcess launchNewMonitor(MonitorLaunchContext context) throws IOException { Path thisJar = JarUtils.thisJarPath(); diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java index 51c0bcba619..79fd19e5dc4 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java @@ -33,19 +33,10 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; -import java.util.Objects; import java.util.function.BiConsumer; import static org.jackhuang.hmcl.util.logging.Logger.LOG; -/// The monitor process role of the monitor feature: creating the game process, supervising it until -/// it exits, and reporting the result. -/// -/// The monitor owns the game process and collects its output in a session log while forwarding it -/// to the main launcher over [the protocol][MonitorProtocol]. After the game exits, it runs the -/// post-exit command and stores the result in memory. When the protocol stream has failed and the -/// relaunch policy requests it, the entry point continues into the launcher UI in this same JVM. -/// No crash result file is written; the crash window reads the stored result and session log. @NotNullByDefault public final class MonitorSupervisor { private static boolean shouldRestart = false; @@ -65,9 +56,7 @@ public static boolean isCrashed() { /// Handles the `--monitor` entry point. /// - /// @param args the process arguments - /// @return `true` when monitor mode has finished and the caller should exit; `false` for - /// non-monitor arguments or when this JVM should continue into the launcher UI + /// @return `true`: should exit, `false`: should continue and launch HMCL public static boolean processArguments(String[] args) { if (args.length != 2 || !args[0].equals("--monitor")) return false; @@ -79,24 +68,19 @@ public static boolean processArguments(String[] args) { return !shouldRestart; } - /// Reads the [ProcessSpec] from `specPath`, creates the process it describes, supervises it - /// until it exits, and reports the result. - /// - /// @param specPath the file containing the [ProcessSpec] - /// @throws IOException if the spec file is malformed or the game process cannot be created - /// @throws InterruptedException if interrupted while waiting for the game process public static void start(Path specPath) throws IOException, InterruptedException { LOG.info("*** HMCL MONITOR ***"); + + // read spec ProcessSpec spec; try { - spec = JsonUtils.fromJsonFile(specPath, ProcessSpec.class); + spec = JsonUtils.fromNonNullJson(Files.readString(specPath), ProcessSpec.class); } catch (JsonParseException e) { throw new IOException("Malformed process spec file " + specPath, e); } - if (spec == null || spec.command == null || spec.command.isEmpty()) - throw new IOException("Malformed process spec file " + specPath); Files.deleteIfExists(specPath); + // start game process ProcessBuilder pb = new ProcessBuilder(spec.command).directory(new File(spec.directory)); pb.environment().putAll(spec.environment); if (spec.inheritStdin) @@ -106,17 +90,11 @@ public static void start(Path specPath) throws IOException, InterruptedException LOG.info("Game process created, pid " + gameProcess.getPid() + ", spec " + specPath); MonitorProtocol.pidMessage(gameProcess.getPid(), gameProcess.getProcessStartTime()); - startMonitors(gameProcess, spec, specPath); - } - - private static void startMonitors(ManagedProcess gameProcess, ProcessSpec spec, Path specPath) - throws InterruptedException { BiConsumer sink = (line, isErrorStream) -> { gameProcess.addLine(line); MonitorProtocol.logMessage(isErrorStream, line); }; - - Charset encoding = Charset.forName(Objects.requireNonNull(spec.encoding)); + Charset encoding = Charset.forName(spec.encoding); Thread stdoutPump = Lang.thread(new StreamPump(gameProcess.getProcess().getInputStream(), line -> sink.accept(line, false), encoding), "monitor-stdout-pump", true); Thread stderrPump = Lang.thread(new StreamPump(gameProcess.getProcess().getErrorStream(), @@ -124,9 +102,19 @@ private static void startMonitors(ManagedProcess gameProcess, ProcessSpec spec, gameProcess.addRelatedThread(stdoutPump); gameProcess.addRelatedThread(stderrPump); + // wait for stop Lang.thread(new ExitWaiter(gameProcess, List.of(stdoutPump, stderrPump), (exitCode, exitType) -> { LOG.info("Game exited with code " + exitCode + "(" + Integer.toHexString(exitCode) + "), type " + exitType); - ProcessListener.ExitType reportedType = readCanceled(specPath) ? ProcessListener.ExitType.INTERRUPTED : exitType; + + // reread spec to check canceled + ProcessListener.ExitType reportedType = exitType; + try { + if (Files.exists(specPath) && Files.readString(specPath).startsWith("CANCELED")) + reportedType = ProcessListener.ExitType.INTERRUPTED; + Files.deleteIfExists(specPath); + } catch (IOException e1) { + LOG.warning("Failed to check whether to cancel " + specPath, e1); + } MonitorProtocol.exitMessage(exitCode, reportedType); crashed = reportedType != ProcessListener.ExitType.NORMAL; @@ -136,6 +124,7 @@ private static void startMonitors(ManagedProcess gameProcess, ProcessSpec spec, case NEVER -> false; }; + // run post exit command if (spec.postExitCommand != null && !spec.postExitCommand.isEmpty()) { try { ProcessBuilder builder = new ProcessBuilder(spec.postExitCommand).directory(spec.launchOptions.getGameDir().toFile()); @@ -149,22 +138,4 @@ private static void startMonitors(ManagedProcess gameProcess, ProcessSpec spec, resultSpec = new ResultSpec(spec, gameProcess, reportedType); }), "exit-waiter", true).join(); } - - /// Returns whether the main launcher rewrote the spec file to mark the game process as manually - /// canceled. A successfully read file is deleted before checking for the `CANCELED` prefix. - /// - /// [MonitorGameProcess#stop] writes the marker before requesting process termination. The - /// caller reports a recognized cancellation as [ProcessListener.ExitType#INTERRUPTED]. - private static boolean readCanceled(Path specPath) { - if (!Files.exists(specPath)) - return false; - try { - String canceled = Files.readString(specPath); - Files.deleteIfExists(specPath); - return canceled.startsWith("CANCELED"); - } catch (IOException e) { - LOG.warning("Failed to check whether to cancel " + specPath, e); - return false; - } - } } diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java index b8d5ee0e651..2da2683d030 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java @@ -29,7 +29,7 @@ @NotNullByDefault public final class ProcessSpec { /// The command line to execute, passed to [ProcessBuilder] verbatim. - public final @Nullable List command; + public final List command; /// The absolute working directory used for the game process and the post-exit command. public final String directory; /// Environment variables overlaid on the monitor's environment for both commands. From f5562114e784b27438613818441a25844869a6b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B1=BC=E5=89=8D=E4=B8=89=E7=99=BE?= Date: Wed, 21 Oct 2026 14:46:37 +0800 Subject: [PATCH 27/44] =?UTF-8?q?refactor:=20=E5=B0=86=20MonitorProtocol?= =?UTF-8?q?=20=E6=B6=88=E6=81=AF=E5=B0=81=E8=A3=85=E4=B8=BA=20sealed=20?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E5=B9=B6=E7=BB=9F=E4=B8=80=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jackhuang/hmcl/monitor/MonitorClient.java | 88 +++++++------------ .../hmcl/monitor/MonitorProtocol.java | 46 +++++++--- 2 files changed, 67 insertions(+), 67 deletions(-) diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java index 7d20964a3f2..3912fc7a1ea 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java @@ -108,6 +108,7 @@ public static MonitorGameProcess launchNewMonitor(MonitorLaunchContext context) List commandline = JavaProcessLauncher.buildJavaCommandLine(thisJar, "--monitor", specFile.toAbsolutePath().toString()); LOG.info("Starting monitor process: " + JavaProcessLauncher.maskCommandLine(commandline)); + Process monitor = new ProcessBuilder(commandline) .directory(Paths.get("").toAbsolutePath().toFile()) .redirectOutput(ProcessBuilder.Redirect.INHERIT) @@ -116,18 +117,10 @@ public static MonitorGameProcess launchNewMonitor(MonitorLaunchContext context) BufferedReader reader = new BufferedReader(new InputStreamReader(monitor.getErrorStream(), MonitorProtocol.CHARSET)); - String[] handshake = readHandshake(monitor, reader); - long pid; - long processStartTime; - try { - pid = Long.parseLong(handshake[1]); - processStartTime = Long.parseLong(handshake[2]); - } catch (NumberFormatException e) { - throw new IOException("Malformed handshake message from HMCL monitor: " + String.join("\t", handshake), e); - } + MonitorProtocol.Pid handshake = readHandshake(monitor, reader); - ProcessHandle gameHandle = ProcessHandle.of(pid).orElse(null); - MonitorGameProcess process = new MonitorGameProcess(gameHandle, context.builder().command(), processStartTime, specFile); + ProcessHandle gameHandle = ProcessHandle.of(handshake.pid()).orElse(null); + MonitorGameProcess process = new MonitorGameProcess(gameHandle, context.builder().command(), handshake.processStartTime(), specFile); ProcessListener listener = context.listener(); if (listener != null) @@ -140,7 +133,8 @@ public static MonitorGameProcess launchNewMonitor(MonitorLaunchContext context) try { String line; while ((line = reader.readLine()) != null) { - dispatchProtocolMessage(line, listener, process); + if (listener != null) + dispatchProtocolMessage(line, listener, process); } } catch (IOException e) { LOG.error("Failed to read from the HMCL monitor", e); @@ -151,33 +145,25 @@ public static MonitorGameProcess launchNewMonitor(MonitorLaunchContext context) } /// Dispatches one protocol message received from the monitor. - private static void dispatchProtocolMessage(String line, @Nullable ProcessListener listener, MonitorGameProcess process) { - String[] parts = line.split("\t", 3); - switch (parts[0]) { - case MonitorProtocol.TAG_LOG -> { - String log = parts.length > 2 ? parts[2] : ""; - if (listener != null) - listener.onLog(log, "1".equals(parts[1])); - } - case MonitorProtocol.TAG_EXIT -> { - if (listener != null && parts.length >= 3) { - try { - int exitCode = Integer.parseInt(parts[1]); - ProcessListener.ExitType exitType = ProcessListener.ExitType.valueOf(parts[2]); - - if (exitType == ProcessListener.ExitType.JVM_ERROR) - EventBus.EVENT_BUS.fireEvent(new JVMLaunchFailedEvent(MonitorClient.class, process)); - else if (exitType != ProcessListener.ExitType.NORMAL) - EventBus.EVENT_BUS.fireEvent(new ProcessExitedAbnormallyEvent(MonitorClient.class, process)); - EventBus.EVENT_BUS.fireEvent(new ProcessStoppedEvent(MonitorClient.class, process)); - - listener.onExit(exitCode, exitType); - } catch (IllegalArgumentException e) { - LOG.warning("Malformed exit message from HMCL monitor: " + line, e); - } - } - } - default -> LOG.warning("Unknown protocol message from HMCL monitor: " + line); + private static void dispatchProtocolMessage(String line, ProcessListener listener, MonitorGameProcess process) { + MonitorProtocol.Message msg; + try { + msg = MonitorProtocol.Message.decode(line); + } catch (IllegalArgumentException e) { + LOG.warning("Unexpected message from HMCL monitor: " + line, e); + return; + } + + if (msg instanceof MonitorProtocol.Log log) { + listener.onLog(log.line(), log.isErrorStream()); + } else if (msg instanceof MonitorProtocol.Exit exit) { + if (exit.exitType() == ProcessListener.ExitType.JVM_ERROR) + EventBus.EVENT_BUS.fireEvent(new JVMLaunchFailedEvent(MonitorClient.class, process)); + else if (exit.exitType() != ProcessListener.ExitType.NORMAL) + EventBus.EVENT_BUS.fireEvent(new ProcessExitedAbnormallyEvent(MonitorClient.class, process)); + EventBus.EVENT_BUS.fireEvent(new ProcessStoppedEvent(MonitorClient.class, process)); + + listener.onExit(exit.exitCode(), exit.exitType()); } } @@ -185,29 +171,26 @@ else if (exitType != ProcessListener.ExitType.NORMAL) /// /// @throws IOException if the monitor exits or fails before the handshake, sends a malformed /// message, or does not answer within [MonitorProtocol#HANDSHAKE_TIMEOUT] - private static String[] readHandshake(Process monitor, BufferedReader reader) throws IOException { - CompletableFuture future = new CompletableFuture<>(); + private static MonitorProtocol.Pid readHandshake(Process monitor, BufferedReader reader) throws IOException { + CompletableFuture future = new CompletableFuture<>(); Thread waiter = Lang.thread(() -> { try { String line = reader.readLine(); - if (line == null) { - future.completeExceptionally(new IOException("The HMCL monitor exited before the handshake")); + if (line == null) + throw new IOException("The HMCL monitor exited before the handshake"); + if (MonitorProtocol.Message.decode(line) instanceof MonitorProtocol.Pid pid) { + future.complete(pid); return; } - String[] parts = line.split("\t"); - if (!MonitorProtocol.TAG_PID.equals(parts[0]) || parts.length < 3) { - future.completeExceptionally(new IOException("Unexpected handshake message from HMCL monitor: " + line)); - } else { - future.complete(parts); - } - } catch (IOException e) { + throw new IOException("Unexpected handshake message from HMCL monitor: " + line); + } catch (Exception e) { future.completeExceptionally(e); } }, "hmcl-monitor-handshake", true); boolean handshakeCompleted = false; try { - String[] handshake = future.get(MonitorProtocol.HANDSHAKE_TIMEOUT, TimeUnit.MILLISECONDS); + MonitorProtocol.Pid handshake = future.get(MonitorProtocol.HANDSHAKE_TIMEOUT, TimeUnit.MILLISECONDS); handshakeCompleted = true; return handshake; } catch (TimeoutException e) { @@ -220,9 +203,6 @@ private static String[] readHandshake(Process monitor, BufferedReader reader) th throw new IOException(cause.getMessage(), cause); } finally { waiter.interrupt(); - // The monitor must not outlive a failed handshake: it would create and supervise the - // game process on its own, leaving behind an orphaned game, e.g. when the user cancels - // the launch while the handshake is still pending. if (!handshakeCompleted) monitor.destroy(); } diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java index 8e29d1e63d0..07c89ba39b0 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java @@ -35,43 +35,63 @@ ///

  • {@code E\t\t} — the game process has exited.
  • /// @NotNullByDefault -public final class MonitorProtocol { +final class MonitorProtocol { /// The charset of the protocol stream. - public static final Charset CHARSET = StandardCharsets.UTF_8; + static final Charset CHARSET = StandardCharsets.UTF_8; /// How long to wait for the monitor's handshake before giving up, in milliseconds. static final long HANDSHAKE_TIMEOUT = 20_000; - /// The shared protocol output on raw stderr, also used to detect failures when writing messages. static private final PrintStream protocol = new PrintStream(new FileOutputStream(FileDescriptor.err), true, MonitorProtocol.CHARSET); - /// Message tag of the handshake message. - static final String TAG_PID = "P"; - /// Message tag of a game output line. - static final String TAG_LOG = "L"; - /// Message tag of the game exit message. - static final String TAG_EXIT = "E"; - private MonitorProtocol() { } /// Writes and flushes the handshake message carrying the game process id and its start time. static void pidMessage(long pid, long processStartTime) { - protocol.println(TAG_PID + "\t" + pid + "\t" + processStartTime); + protocol.println("P\t" + pid + "\t" + processStartTime); } /// Writes and flushes a game output line message. static void logMessage(boolean isErrorStream, String line) { - protocol.println(TAG_LOG + "\t" + (isErrorStream ? 1 : 0) + "\t" + line); + protocol.println("L\t" + (isErrorStream ? 1 : 0) + "\t" + line); } /// Writes and flushes the game exit message carrying the exit code and its classified type. static void exitMessage(int exitCode, ProcessListener.ExitType exitType) { - protocol.println(TAG_EXIT + "\t" + exitCode + "\t" + exitType.name()); + protocol.println("E\t" + exitCode + "\t" + exitType.name()); } static boolean parentGone() { return protocol.checkError(); } + + sealed interface Message permits Pid, Log, Exit { + static Message decode(String message) throws IllegalArgumentException { + String[] parts = message.split("\t", 3); + try { + return switch (parts[0]) { + case "P" -> new Pid(Long.parseLong(parts[1]), Long.parseLong(parts[2])); + case "L" -> new Log("1".equals(parts[1]), parts[2]); + case "E" -> new Exit(Integer.parseInt(parts[1]), ProcessListener.ExitType.valueOf(parts[2])); + default -> throw new IllegalStateException("Unexpected value: " + message.charAt(0)); + }; + } catch (Exception e) { + throw new IllegalArgumentException(e); + } + } + } + + /// handshake message + record Pid(long pid, long processStartTime) implements Message { + } + + /// game output line + record Log(boolean isErrorStream, String line) implements Message { + } + + /// game exit message + record Exit(int exitCode, ProcessListener.ExitType exitType) implements Message { + } } From c3fcf7056231db10ab5e15e9e333d7543d39f6b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B1=BC=E5=89=8D=E4=B8=89=E7=99=BE?= Date: Wed, 21 Oct 2026 14:55:06 +0800 Subject: [PATCH 28/44] =?UTF-8?q?refactor:=20=E9=87=8D=E5=91=BD=E5=90=8D?= =?UTF-8?q?=20MonitorClient=20=E4=B8=BA=20MonitorLauncher=20=E5=B9=B6?= =?UTF-8?q?=E5=B0=86=20MonitorSupervisor=20=E6=94=B9=E4=B8=BA=20Monitor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/org/jackhuang/hmcl/EntryPoint.java | 4 ++-- .../main/java/org/jackhuang/hmcl/Launcher.java | 4 ++-- .../jackhuang/hmcl/game/HMCLGameLauncher.java | 16 ++++++++-------- .../org/jackhuang/hmcl/game/LauncherHelper.java | 4 ++-- .../hmcl/monitor/MonitorCrashReporter.java | 2 +- .../{MonitorSupervisor.java => Monitor.java} | 4 ++-- .../{MonitorClient.java => MonitorLauncher.java} | 16 +++++----------- .../org/jackhuang/hmcl/monitor/ProcessSpec.java | 4 ++-- 8 files changed, 24 insertions(+), 30 deletions(-) rename HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/{MonitorSupervisor.java => Monitor.java} (98%) rename HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/{MonitorClient.java => MonitorLauncher.java} (93%) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/EntryPoint.java b/HMCL/src/main/java/org/jackhuang/hmcl/EntryPoint.java index 05116c39937..4aa01d77693 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/EntryPoint.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/EntryPoint.java @@ -20,7 +20,7 @@ import org.jackhuang.hmcl.util.FileSaver; import org.jackhuang.hmcl.util.SelfDependencyPatcher; import org.jackhuang.hmcl.util.SwingUtils; -import org.jackhuang.hmcl.monitor.MonitorSupervisor; +import org.jackhuang.hmcl.monitor.Monitor; import org.jackhuang.hmcl.java.JavaRuntime; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.io.JarUtils; @@ -52,7 +52,7 @@ public static void main(String[] args) { LOG.start(Metadata.HMCL_LOCAL_HOME.resolve("logs")); // start HMCL monitor before JavaFX initialized - if (MonitorSupervisor.processArguments(args)) { + if (Monitor.processArguments(args)) { exit(0); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java b/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java index 4a753bd4b76..46915eb760c 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java @@ -36,7 +36,7 @@ import javafx.util.Duration; import org.jackhuang.hmcl.monitor.MonitorCrashReporter; import org.jackhuang.hmcl.game.HMCLCacheRepository; -import org.jackhuang.hmcl.monitor.MonitorSupervisor; +import org.jackhuang.hmcl.monitor.Monitor; import org.jackhuang.hmcl.setting.*; import org.jackhuang.hmcl.task.AsyncTaskExecutor; import org.jackhuang.hmcl.task.Schedulers; @@ -149,7 +149,7 @@ public void start(Stage primaryStage) { primaryStage.show(); // check whether to show log from HMCL monitor - if (MonitorSupervisor.isCrashed()) + if (Monitor.isCrashed()) MonitorCrashReporter.show(); }); } catch (Throwable e) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java index 97b8af730e6..bec2c7af11c 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java @@ -21,7 +21,7 @@ import org.jackhuang.hmcl.auth.AuthInfo; import org.jackhuang.hmcl.launch.DefaultLauncher; import org.jackhuang.hmcl.launch.ProcessListener; -import org.jackhuang.hmcl.monitor.MonitorClient; +import org.jackhuang.hmcl.monitor.MonitorLauncher; import org.jackhuang.hmcl.setting.GameSettings; import org.jackhuang.hmcl.setting.LauncherVisibility; import org.jackhuang.hmcl.util.NativePatcher; @@ -166,11 +166,11 @@ public ProcessInfo launch() throws IOException, InterruptedException { @Override protected ProcessInfo startProcess(ProcessBuilder builder, Charset encoding, Path nativeFolder) throws IOException { - if (!MonitorClient.isEnabled()) + if (!MonitorLauncher.isEnabled()) return super.startProcess(builder, encoding, nativeFolder); String gameDirectoryId = null; - MonitorClient.RelaunchPolicy relaunchPolicy = MonitorClient.RelaunchPolicy.NEVER; + MonitorLauncher.RelaunchPolicy relaunchPolicy = MonitorLauncher.RelaunchPolicy.NEVER; if (instance instanceof HMCLGameInstance hmclGameInstance) { gameDirectoryId = hmclGameInstance.getRepository().getGameDirectory().getId().toString(); @@ -178,11 +178,11 @@ protected ProcessInfo startProcess(ProcessBuilder builder, Charset encoding, Pat LauncherVisibility x = setting.getInheritable(GameSettings::launcherVisibilityProperty); boolean showLogs = setting.getInheritable(GameSettings::showLogsProperty); relaunchPolicy = showLogs - ? MonitorClient.RelaunchPolicy.NEVER + ? MonitorLauncher.RelaunchPolicy.NEVER : switch (x) { - case HIDE_AND_REOPEN -> MonitorClient.RelaunchPolicy.ALWAYS; - case HIDE -> MonitorClient.RelaunchPolicy.ON_CRASH; - default -> MonitorClient.RelaunchPolicy.NEVER; + case HIDE_AND_REOPEN -> MonitorLauncher.RelaunchPolicy.ALWAYS; + case HIDE -> MonitorLauncher.RelaunchPolicy.ON_CRASH; + default -> MonitorLauncher.RelaunchPolicy.NEVER; }; } @@ -190,7 +190,7 @@ protected ProcessInfo startProcess(ProcessBuilder builder, Charset encoding, Pat if (StringUtils.isNotBlank(options.getPostExitCommand())) postExitCommand = StringUtils.tokenize(options.getPostExitCommand(), getEnvVars(nativeFolder)); - return MonitorClient.launchNewMonitor(new MonitorClient.MonitorLaunchContext(builder, listener, + return MonitorLauncher.launchNewMonitor(new MonitorLauncher.MonitorLaunchContext(builder, listener, encoding, options, gameDirectoryId, postExitCommand, relaunchPolicy)); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java index ba52f316431..df7a3a7a041 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -23,7 +23,7 @@ import org.jackhuang.hmcl.auth.*; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDownloadException; import org.jackhuang.hmcl.auth.offline.OfflineAccount; -import org.jackhuang.hmcl.monitor.MonitorClient; +import org.jackhuang.hmcl.monitor.MonitorLauncher; import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.DownloadProvider; import org.jackhuang.hmcl.download.game.*; @@ -994,7 +994,7 @@ public void onLog(String log, boolean isErrorStream) { try { if (!lwjgl) { lwjgl = true; - if (MonitorClient.isEnabled() && !showLogs + if (MonitorLauncher.isEnabled() && !showLogs && (launcherVisibility == LauncherVisibility.HIDE || launcherVisibility == LauncherVisibility.HIDE_AND_REOPEN)) { launchingLatch.countDown(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java index 6af42fe2574..83a7659ffe5 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java @@ -45,7 +45,7 @@ private MonitorCrashReporter() { } public static void show() { - ResultSpec result = MonitorSupervisor.getResultSpec(); + ResultSpec result = Monitor.getResultSpec(); if (result == null || result.gameDirectoryId() == null) return; diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java similarity index 98% rename from HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java rename to HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java index 79fd19e5dc4..f283715ef54 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorSupervisor.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java @@ -38,12 +38,12 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG; @NotNullByDefault -public final class MonitorSupervisor { +public final class Monitor { private static boolean shouldRestart = false; private static boolean crashed = false; private static @Nullable ResultSpec resultSpec; - private MonitorSupervisor() { + private Monitor() { } public static @Nullable ResultSpec getResultSpec() { diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java similarity index 93% rename from HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java rename to HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java index 3912fc7a1ea..bc7e7479396 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorClient.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java @@ -50,13 +50,11 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG; -/// The main launcher process role of the monitor feature: launching the game process through a -/// freshly spawned monitor process and consuming its protocol stream. @NotNullByDefault -public final class MonitorClient { +public final class MonitorLauncher { private static final boolean MONITOR_ENABLED = System.getenv("HMCL_USE_HMCLMONITOR") != null; - private MonitorClient() { + private MonitorLauncher() { } public static boolean isEnabled() { @@ -118,7 +116,6 @@ public static MonitorGameProcess launchNewMonitor(MonitorLaunchContext context) BufferedReader reader = new BufferedReader(new InputStreamReader(monitor.getErrorStream(), MonitorProtocol.CHARSET)); MonitorProtocol.Pid handshake = readHandshake(monitor, reader); - ProcessHandle gameHandle = ProcessHandle.of(handshake.pid()).orElse(null); MonitorGameProcess process = new MonitorGameProcess(gameHandle, context.builder().command(), handshake.processStartTime(), specFile); @@ -126,9 +123,6 @@ public static MonitorGameProcess launchNewMonitor(MonitorLaunchContext context) if (listener != null) listener.setProcess(process); - // The reader thread must drain the protocol stream until the monitor exits even without a - // listener: the exit message of a canceled launch is dispatched here, and an undrained - // pipe would eventually block the monitor. Lang.thread(() -> { try { String line; @@ -158,10 +152,10 @@ private static void dispatchProtocolMessage(String line, ProcessListener listene listener.onLog(log.line(), log.isErrorStream()); } else if (msg instanceof MonitorProtocol.Exit exit) { if (exit.exitType() == ProcessListener.ExitType.JVM_ERROR) - EventBus.EVENT_BUS.fireEvent(new JVMLaunchFailedEvent(MonitorClient.class, process)); + EventBus.EVENT_BUS.fireEvent(new JVMLaunchFailedEvent(MonitorLauncher.class, process)); else if (exit.exitType() != ProcessListener.ExitType.NORMAL) - EventBus.EVENT_BUS.fireEvent(new ProcessExitedAbnormallyEvent(MonitorClient.class, process)); - EventBus.EVENT_BUS.fireEvent(new ProcessStoppedEvent(MonitorClient.class, process)); + EventBus.EVENT_BUS.fireEvent(new ProcessExitedAbnormallyEvent(MonitorLauncher.class, process)); + EventBus.EVENT_BUS.fireEvent(new ProcessStoppedEvent(MonitorLauncher.class, process)); listener.onExit(exit.exitCode(), exit.exitType()); } diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java index 2da2683d030..248297e49d7 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java @@ -43,7 +43,7 @@ public final class ProcessSpec { public final @Nullable List postExitCommand; /// The policy for opening the launcher UI in the monitor JVM after the protocol stream fails. - public final MonitorClient.RelaunchPolicy relaunchPolicy; + public final MonitorLauncher.RelaunchPolicy relaunchPolicy; /// The persistent id of the registered game directory owning the launched instance, or `null` /// when unknown. public final @Nullable String gameDirectoryId; @@ -52,7 +52,7 @@ public final class ProcessSpec { public final LaunchOptions launchOptions; /// Creates a process specification from a launch context whose builder has a working directory. - public ProcessSpec(MonitorClient.MonitorLaunchContext context) { + public ProcessSpec(MonitorLauncher.MonitorLaunchContext context) { ProcessBuilder builder = context.builder(); command = new ArrayList<>(builder.command()); From 54f59c6b9d4f8039a07471beb7207fa908cf99ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B1=BC=E5=89=8D=E4=B8=89=E7=99=BE?= Date: Wed, 21 Oct 2026 15:13:31 +0800 Subject: [PATCH 29/44] =?UTF-8?q?refactor:=20=E5=B0=86=20ResultSpec=20?= =?UTF-8?q?=E5=86=85=E8=81=94=E4=B8=BA=20Monitor.Result=20=E5=B9=B6?= =?UTF-8?q?=E5=A4=8D=E7=94=A8=E5=8F=96=E6=B6=88=E6=A0=87=E8=AE=B0=E5=B8=B8?= =?UTF-8?q?=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/monitor/MonitorCrashReporter.java | 4 +- .../org/jackhuang/hmcl/monitor/Monitor.java | 27 ++++++++-- .../hmcl/monitor/MonitorGameProcess.java | 18 +++---- .../hmcl/monitor/MonitorLauncher.java | 54 +++++++++---------- .../hmcl/monitor/MonitorProtocol.java | 2 - .../jackhuang/hmcl/monitor/ResultSpec.java | 44 --------------- 6 files changed, 59 insertions(+), 90 deletions(-) delete mode 100644 HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ResultSpec.java diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java index 83a7659ffe5..c59ed27ffc9 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java @@ -45,7 +45,7 @@ private MonitorCrashReporter() { } public static void show() { - ResultSpec result = Monitor.getResultSpec(); + Monitor.Result result = Monitor.getResultSpec(); if (result == null || result.gameDirectoryId() == null) return; @@ -82,7 +82,7 @@ public static void show() { } /// Schedules the crash window on the JavaFX thread using the configured tail of captured console output. - private static void presentCrashWindow(ResultSpec result, HMCLGameInstance instance) { + private static void presentCrashWindow(Monitor.Result result, HMCLGameInstance instance) { @Unmodifiable List logs = result.process().getLines(null); Platform.runLater(() -> new GameCrashWindow( result.process(), diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java index f283715ef54..92b4fb739e5 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java @@ -18,6 +18,7 @@ package org.jackhuang.hmcl.monitor; import com.google.gson.JsonParseException; +import org.jackhuang.hmcl.game.LaunchOptions; import org.jackhuang.hmcl.launch.ExitWaiter; import org.jackhuang.hmcl.launch.ProcessListener; import org.jackhuang.hmcl.launch.StreamPump; @@ -39,15 +40,16 @@ @NotNullByDefault public final class Monitor { + public static final String CANCELED = "CANCELED"; private static boolean shouldRestart = false; private static boolean crashed = false; - private static @Nullable ResultSpec resultSpec; + private static @Nullable Monitor.Result result; private Monitor() { } - public static @Nullable ResultSpec getResultSpec() { - return resultSpec; + public static @Nullable Monitor.Result getResultSpec() { + return result; } public static boolean isCrashed() { @@ -109,7 +111,7 @@ public static void start(Path specPath) throws IOException, InterruptedException // reread spec to check canceled ProcessListener.ExitType reportedType = exitType; try { - if (Files.exists(specPath) && Files.readString(specPath).startsWith("CANCELED")) + if (Files.exists(specPath) && Files.readString(specPath).startsWith(CANCELED)) reportedType = ProcessListener.ExitType.INTERRUPTED; Files.deleteIfExists(specPath); } catch (IOException e1) { @@ -135,7 +137,22 @@ public static void start(Path specPath) throws IOException, InterruptedException } } - resultSpec = new ResultSpec(spec, gameProcess, reportedType); + result = new Result(gameProcess, reportedType, spec.gameDirectoryId, spec.command, spec.launchOptions); }), "exit-waiter", true).join(); } + + /// The in-memory result of a supervised game process, retained when the monitor JVM continues + /// into the launcher UI to present the game crash window. + /// + /// @param process The supervised game process, which has already exited. + /// @param exitType The reported exit type, including `INTERRUPTED` for a recognized cancellation. + /// @param gameDirectoryId The persistent id of the registered game directory owning the launched + /// instance, or `null` when unknown. + /// @param commands The command line of the game process, or `null` when unknown. + /// @param launchOptions The launch options used to identify the instance and present its crash window. + @NotNullByDefault + public record Result(ManagedProcess process, ProcessListener.ExitType exitType, + @Nullable String gameDirectoryId, @Nullable List commands, + LaunchOptions launchOptions) { + } } diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java index 68c33185a87..fe319119872 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java @@ -40,8 +40,8 @@ public final class MonitorGameProcess implements ProcessInfo { private final long pid; private final List commands; private final long processStartTime; - /// The file the spec was exchanged with the monitor through, or `null` when unknown. - private final @Nullable Path specFile; + /// The file the spec was exchanged with the monitor through + private final Path specFile; /// Creates a view of the monitor-supervised game process. /// @@ -50,7 +50,7 @@ public final class MonitorGameProcess implements ProcessInfo { /// @param processStartTime the epoch millisecond at which the game process started /// @param specFile the file the spec was exchanged with the monitor through, or `null` if unknown MonitorGameProcess(@Nullable ProcessHandle handle, List commands, long processStartTime, - @Nullable Path specFile) { + Path specFile) { this.handle = handle; this.pid = handle != null ? handle.pid() : -1; this.commands = List.copyOf(commands); @@ -91,13 +91,11 @@ public boolean isRunning() { @Override public void stop() { - if (specFile != null) - try { - // notify the monitor - Files.writeString(specFile, "CANCELED"); - } catch (IOException e) { - LOG.warning("Failed to rewrite the monitor spec file " + specFile + " to mark the cancellation", e); - } + try { // notify the monitor + Files.writeString(specFile, Monitor.CANCELED); + } catch (IOException e) { + LOG.warning("Failed to rewrite the monitor spec file " + specFile + " to mark the cancellation", e); + } if (handle != null) handle.destroy(); } diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java index bc7e7479396..81607e2baa5 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java @@ -53,32 +53,6 @@ @NotNullByDefault public final class MonitorLauncher { private static final boolean MONITOR_ENABLED = System.getenv("HMCL_USE_HMCLMONITOR") != null; - - private MonitorLauncher() { - } - - public static boolean isEnabled() { - return MONITOR_ENABLED; - } - - public record MonitorLaunchContext( - ProcessBuilder builder, - @Nullable ProcessListener listener, - Charset encoding, - LaunchOptions options, - @Nullable String gameDirectoryId, - @Nullable List postExitCommand, - RelaunchPolicy relaunchPolicy) {} - - public enum RelaunchPolicy { - /// Never continue into the launcher UI after the game exits. - NEVER, - /// Continue into the launcher UI after a non-normal exit if the protocol stream has failed. - ON_CRASH, - /// Continue into the launcher UI after any exit if the protocol stream has failed. - ALWAYS - } - private static final Gson gsonForGameOptions = JsonUtils.defaultGsonBuilder() .addSerializationExclusionStrategy(new ExclusionStrategy() { @Override @@ -93,6 +67,13 @@ public boolean shouldSkipClass(Class clazz) { } }).create(); + private MonitorLauncher() { + } + + public static boolean isEnabled() { + return MONITOR_ENABLED; + } + public static MonitorGameProcess launchNewMonitor(MonitorLaunchContext context) throws IOException { Path thisJar = JarUtils.thisJarPath(); if (thisJar == null) @@ -142,7 +123,7 @@ public static MonitorGameProcess launchNewMonitor(MonitorLaunchContext context) private static void dispatchProtocolMessage(String line, ProcessListener listener, MonitorGameProcess process) { MonitorProtocol.Message msg; try { - msg = MonitorProtocol.Message.decode(line); + msg = MonitorProtocol.Message.decode(line); } catch (IllegalArgumentException e) { LOG.warning("Unexpected message from HMCL monitor: " + line, e); return; @@ -201,4 +182,23 @@ private static MonitorProtocol.Pid readHandshake(Process monitor, BufferedReader monitor.destroy(); } } + + public enum RelaunchPolicy { + /// Never continue into the launcher UI after the game exits. + NEVER, + /// Continue into the launcher UI after a non-normal exit if the protocol stream has failed. + ON_CRASH, + /// Continue into the launcher UI after any exit if the protocol stream has failed. + ALWAYS + } + + public record MonitorLaunchContext( + ProcessBuilder builder, + @Nullable ProcessListener listener, + Charset encoding, + LaunchOptions options, + @Nullable String gameDirectoryId, + @Nullable List postExitCommand, + RelaunchPolicy relaunchPolicy) { + } } diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java index 07c89ba39b0..c691ebf051a 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java @@ -36,10 +36,8 @@ /// @NotNullByDefault final class MonitorProtocol { - /// The charset of the protocol stream. static final Charset CHARSET = StandardCharsets.UTF_8; - /// How long to wait for the monitor's handshake before giving up, in milliseconds. static final long HANDSHAKE_TIMEOUT = 20_000; /// The shared protocol output on raw stderr, also used to detect failures when writing messages. diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ResultSpec.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ResultSpec.java deleted file mode 100644 index 661f79ab86e..00000000000 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ResultSpec.java +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2026 huangyuhui 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 . - */ -package org.jackhuang.hmcl.monitor; - -import org.jackhuang.hmcl.game.LaunchOptions; -import org.jackhuang.hmcl.launch.ProcessListener; -import org.jackhuang.hmcl.util.platform.ManagedProcess; -import org.jetbrains.annotations.NotNullByDefault; -import org.jetbrains.annotations.Nullable; - -import java.util.List; - -/// The in-memory result of a supervised game process, retained when the monitor JVM continues -/// into the launcher UI to present the game crash window. -/// -/// @param process The supervised game process, which has already exited. -/// @param exitType The reported exit type, including `INTERRUPTED` for a recognized cancellation. -/// @param gameDirectoryId The persistent id of the registered game directory owning the launched -/// instance, or `null` when unknown. -/// @param commands The command line of the game process, or `null` when unknown. -/// @param launchOptions The launch options used to identify the instance and present its crash window. -@NotNullByDefault -public record ResultSpec(ManagedProcess process, ProcessListener.ExitType exitType, - @Nullable String gameDirectoryId, @Nullable List commands, LaunchOptions launchOptions) { - /// Combines the launch specification with the supervised process and its reported exit result. - public ResultSpec(ProcessSpec spec, ManagedProcess process, ProcessListener.ExitType exitType) { - this(process, exitType, spec.gameDirectoryId, spec.command, spec.launchOptions); - } -} From df8c48c1df1996c1705498f3df84f631a9ea7391 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B1=BC=E5=89=8D=E4=B8=89=E7=99=BE?= Date: Wed, 21 Oct 2026 15:42:00 +0800 Subject: [PATCH 30/44] =?UTF-8?q?refactor:=20=E5=B0=86=20ProcessSpec=20?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=20record=20=E5=B9=B6=E6=94=B9=E7=94=A8?= =?UTF-8?q?=E8=AE=BF=E9=97=AE=E5=99=A8=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/jackhuang/hmcl/monitor/Monitor.java | 20 +++--- .../jackhuang/hmcl/monitor/ProcessSpec.java | 62 ++++++++----------- 2 files changed, 35 insertions(+), 47 deletions(-) diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java index 92b4fb739e5..993c1e655a1 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java @@ -83,11 +83,11 @@ public static void start(Path specPath) throws IOException, InterruptedException Files.deleteIfExists(specPath); // start game process - ProcessBuilder pb = new ProcessBuilder(spec.command).directory(new File(spec.directory)); - pb.environment().putAll(spec.environment); - if (spec.inheritStdin) + ProcessBuilder pb = new ProcessBuilder(spec.command()).directory(new File(spec.directory())); + pb.environment().putAll(spec.environment()); + if (spec.inheritStdin()) pb.redirectInput(ProcessBuilder.Redirect.INHERIT); - ManagedProcess gameProcess = new ManagedProcess(pb.start(), spec.command); + ManagedProcess gameProcess = new ManagedProcess(pb.start(), spec.command()); LOG.info("Game process created, pid " + gameProcess.getPid() + ", spec " + specPath); MonitorProtocol.pidMessage(gameProcess.getPid(), gameProcess.getProcessStartTime()); @@ -96,7 +96,7 @@ public static void start(Path specPath) throws IOException, InterruptedException gameProcess.addLine(line); MonitorProtocol.logMessage(isErrorStream, line); }; - Charset encoding = Charset.forName(spec.encoding); + Charset encoding = Charset.forName(spec.encoding()); Thread stdoutPump = Lang.thread(new StreamPump(gameProcess.getProcess().getInputStream(), line -> sink.accept(line, false), encoding), "monitor-stdout-pump", true); Thread stderrPump = Lang.thread(new StreamPump(gameProcess.getProcess().getErrorStream(), @@ -120,24 +120,24 @@ public static void start(Path specPath) throws IOException, InterruptedException MonitorProtocol.exitMessage(exitCode, reportedType); crashed = reportedType != ProcessListener.ExitType.NORMAL; - shouldRestart = MonitorProtocol.parentGone() && switch (spec.relaunchPolicy) { + shouldRestart = MonitorProtocol.parentGone() && switch (spec.relaunchPolicy()) { case ALWAYS -> true; case ON_CRASH -> crashed; case NEVER -> false; }; // run post exit command - if (spec.postExitCommand != null && !spec.postExitCommand.isEmpty()) { + if (spec.postExitCommand() != null && !spec.postExitCommand().isEmpty()) { try { - ProcessBuilder builder = new ProcessBuilder(spec.postExitCommand).directory(spec.launchOptions.getGameDir().toFile()); - builder.environment().putAll(spec.environment); + ProcessBuilder builder = new ProcessBuilder(spec.postExitCommand()).directory(spec.launchOptions().getGameDir().toFile()); + builder.environment().putAll(spec.environment()); SystemUtils.callExternalProcess(builder); } catch (Throwable e) { LOG.warning("An Exception happened while running exit command.", e); } } - result = new Result(gameProcess, reportedType, spec.gameDirectoryId, spec.command, spec.launchOptions); + result = new Result(gameProcess, reportedType, spec.gameDirectoryId(), spec.command(), spec.launchOptions()); }), "exit-waiter", true).join(); } diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java index 248297e49d7..10fc0e8e38b 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java @@ -26,45 +26,33 @@ import java.util.Map; /// The specification of the game process to supervise, exchanged with the monitor process as JSON. +/// +/// @param command The command line to execute, passed to [ProcessBuilder] verbatim. +/// @param directory The absolute working directory used for the game process and the post-exit command. +/// @param environment Environment variables overlaid on the monitor's environment for both commands. +/// @param encoding The name of the charset used to decode the game process output. +/// @param inheritStdin Whether the game process should inherit the monitor's standard input; set when no process +/// listener is present. +/// @param postExitCommand The post-exit command to run after the game exits, already tokenized, or `null`. +/// @param relaunchPolicy The policy for opening the launcher UI in the monitor JVM after the protocol stream fails. +/// @param gameDirectoryId The persistent id of the registered game directory owning the launched instance, or `null` +/// when unknown. +/// @param launchOptions The launch options retained for presenting the game crash window. @NotNullByDefault -public final class ProcessSpec { - /// The command line to execute, passed to [ProcessBuilder] verbatim. - public final List command; - /// The absolute working directory used for the game process and the post-exit command. - public final String directory; - /// Environment variables overlaid on the monitor's environment for both commands. - public final Map environment; - /// The name of the charset used to decode the game process output. - public final String encoding; - /// Whether the game process should inherit the monitor's standard input; set when no process - /// listener is present. - public final boolean inheritStdin; - /// The post-exit command to run after the game exits, already tokenized, or `null`. - public final @Nullable List postExitCommand; - - /// The policy for opening the launcher UI in the monitor JVM after the protocol stream fails. - public final MonitorLauncher.RelaunchPolicy relaunchPolicy; - /// The persistent id of the registered game directory owning the launched instance, or `null` - /// when unknown. - public final @Nullable String gameDirectoryId; - - /// The launch options retained for presenting the game crash window. - public final LaunchOptions launchOptions; - +public record ProcessSpec(List command, String directory, Map environment, String encoding, + boolean inheritStdin, @Nullable List postExitCommand, + MonitorLauncher.RelaunchPolicy relaunchPolicy, @Nullable String gameDirectoryId, + LaunchOptions launchOptions) { /// Creates a process specification from a launch context whose builder has a working directory. public ProcessSpec(MonitorLauncher.MonitorLaunchContext context) { - ProcessBuilder builder = context.builder(); - - command = new ArrayList<>(builder.command()); - directory = builder.directory().getAbsolutePath(); - environment = builder.environment(); - - encoding = context.encoding().name(); - inheritStdin = context.listener() == null; - postExitCommand = context.postExitCommand(); - relaunchPolicy = context.relaunchPolicy(); - gameDirectoryId = context.gameDirectoryId(); - - launchOptions = context.options(); + this(new ArrayList<>(context.builder().command()), + context.builder().directory().getAbsolutePath(), + context.builder().environment(), + context.encoding().name(), + context.listener() == null, + context.postExitCommand(), + context.relaunchPolicy(), + context.gameDirectoryId(), + context.options()); } } From 23e9c3da2ac9bcc3edbea7d352878f44e6af7b09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B1=BC=E5=89=8D=E4=B8=89=E7=99=BE?= Date: Wed, 21 Oct 2026 15:48:59 +0800 Subject: [PATCH 31/44] =?UTF-8?q?refactor:=20=E5=B0=86=20ProcessSpec=20?= =?UTF-8?q?=E5=86=85=E8=81=94=E4=B8=BA=20Monitor.ProcessSpec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/jackhuang/hmcl/monitor/Monitor.java | 34 +++++++++++ .../hmcl/monitor/MonitorLauncher.java | 2 +- .../jackhuang/hmcl/monitor/ProcessSpec.java | 58 ------------------- 3 files changed, 35 insertions(+), 59 deletions(-) delete mode 100644 HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java index 993c1e655a1..183c28d821b 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java @@ -33,7 +33,9 @@ import java.nio.charset.Charset; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.function.BiConsumer; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -155,4 +157,36 @@ public record Result(ManagedProcess process, ProcessListener.ExitType exitType, @Nullable String gameDirectoryId, @Nullable List commands, LaunchOptions launchOptions) { } + + /// The specification of the game process to supervise, exchanged with the monitor process as JSON. + /// + /// @param command The command line to execute, passed to [ProcessBuilder] verbatim. + /// @param directory The absolute working directory used for the game process and the post-exit command. + /// @param environment Environment variables overlaid on the monitor's environment for both commands. + /// @param encoding The name of the charset used to decode the game process output. + /// @param inheritStdin Whether the game process should inherit the monitor's standard input; set when no process + /// listener is present. + /// @param postExitCommand The post-exit command to run after the game exits, already tokenized, or `null`. + /// @param relaunchPolicy The policy for opening the launcher UI in the monitor JVM after the protocol stream fails. + /// @param gameDirectoryId The persistent id of the registered game directory owning the launched instance, or `null` + /// when unknown. + /// @param launchOptions The launch options retained for presenting the game crash window. + @NotNullByDefault + public record ProcessSpec(List command, String directory, Map environment, String encoding, + boolean inheritStdin, @Nullable List postExitCommand, + MonitorLauncher.RelaunchPolicy relaunchPolicy, @Nullable String gameDirectoryId, + LaunchOptions launchOptions) { + /// Creates a process specification from a launch context whose builder has a working directory. + public ProcessSpec(MonitorLauncher.MonitorLaunchContext context) { + this(new ArrayList<>(context.builder().command()), + context.builder().directory().getAbsolutePath(), + context.builder().environment(), + context.encoding().name(), + context.listener() == null, + context.postExitCommand(), + context.relaunchPolicy(), + context.gameDirectoryId(), + context.options()); + } + } } diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java index 81607e2baa5..304badf63da 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java @@ -81,7 +81,7 @@ public static MonitorGameProcess launchNewMonitor(MonitorLaunchContext context) Path specFile = Files.createTempFile("hmcl-monitor-spec-", ".json"); try (var writer = Files.newBufferedWriter(specFile)) { - gsonForGameOptions.toJson(new ProcessSpec(context), writer); + gsonForGameOptions.toJson(new Monitor.ProcessSpec(context), writer); } List commandline = JavaProcessLauncher.buildJavaCommandLine(thisJar, diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java deleted file mode 100644 index 10fc0e8e38b..00000000000 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/ProcessSpec.java +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2026 huangyuhui 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 . - */ -package org.jackhuang.hmcl.monitor; - -import org.jackhuang.hmcl.game.LaunchOptions; -import org.jetbrains.annotations.NotNullByDefault; -import org.jetbrains.annotations.Nullable; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -/// The specification of the game process to supervise, exchanged with the monitor process as JSON. -/// -/// @param command The command line to execute, passed to [ProcessBuilder] verbatim. -/// @param directory The absolute working directory used for the game process and the post-exit command. -/// @param environment Environment variables overlaid on the monitor's environment for both commands. -/// @param encoding The name of the charset used to decode the game process output. -/// @param inheritStdin Whether the game process should inherit the monitor's standard input; set when no process -/// listener is present. -/// @param postExitCommand The post-exit command to run after the game exits, already tokenized, or `null`. -/// @param relaunchPolicy The policy for opening the launcher UI in the monitor JVM after the protocol stream fails. -/// @param gameDirectoryId The persistent id of the registered game directory owning the launched instance, or `null` -/// when unknown. -/// @param launchOptions The launch options retained for presenting the game crash window. -@NotNullByDefault -public record ProcessSpec(List command, String directory, Map environment, String encoding, - boolean inheritStdin, @Nullable List postExitCommand, - MonitorLauncher.RelaunchPolicy relaunchPolicy, @Nullable String gameDirectoryId, - LaunchOptions launchOptions) { - /// Creates a process specification from a launch context whose builder has a working directory. - public ProcessSpec(MonitorLauncher.MonitorLaunchContext context) { - this(new ArrayList<>(context.builder().command()), - context.builder().directory().getAbsolutePath(), - context.builder().environment(), - context.encoding().name(), - context.listener() == null, - context.postExitCommand(), - context.relaunchPolicy(), - context.gameDirectoryId(), - context.options()); - } -} From f1286efa8fc1f2a36b8ddc4550e454968cda6d43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B1=BC=E5=89=8D=E4=B8=89=E7=99=BE?= Date: Wed, 21 Oct 2026 16:04:13 +0800 Subject: [PATCH 32/44] =?UTF-8?q?refactor:=20=E5=B0=86=20ProcessSpec=20?= =?UTF-8?q?=E9=87=8D=E5=91=BD=E5=90=8D=E4=B8=BA=20MonitorLaunchSpec=20?= =?UTF-8?q?=E5=B9=B6=E7=A7=BB=E5=8A=A8=E5=88=B0=20MonitorLauncher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/monitor/MonitorCrashReporter.java | 10 ++--- .../org/jackhuang/hmcl/monitor/Monitor.java | 42 ++----------------- .../hmcl/monitor/MonitorLauncher.java | 23 +++++++++- 3 files changed, 27 insertions(+), 48 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java index c59ed27ffc9..a32470d58be 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java @@ -45,7 +45,7 @@ private MonitorCrashReporter() { } public static void show() { - Monitor.Result result = Monitor.getResultSpec(); + Monitor.Result result = Monitor.getResult(); if (result == null || result.gameDirectoryId() == null) return; @@ -72,12 +72,8 @@ public static void show() { HMCLGameInstance loaded = repository.findInstance(instanceId); if (loaded != null) presentCrashWindow(result, loaded); - else // TODO 把这里的日志改短一点 - LOG.warning("Cannot resolve the launched instance " + instanceId + " (" - + repository.getSnapshot().getInstanceCount() + " instances, loaded=" - + repository.isLoaded() + ", base directory " - + repository.getLayout().getBaseDirectory() - + "), skip showing the crash window."); + else + LOG.warning("Cannot resolve the launched instance " + instanceId + ", skip showing the crash window"); }).start(); } diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java index 183c28d821b..8a892d294b8 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java @@ -33,9 +33,7 @@ import java.nio.charset.Charset; import java.nio.file.Files; import java.nio.file.Path; -import java.util.ArrayList; import java.util.List; -import java.util.Map; import java.util.function.BiConsumer; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -50,7 +48,7 @@ public final class Monitor { private Monitor() { } - public static @Nullable Monitor.Result getResultSpec() { + public static @Nullable Monitor.Result getResult() { return result; } @@ -58,8 +56,6 @@ public static boolean isCrashed() { return crashed; } - /// Handles the `--monitor` entry point. - /// /// @return `true`: should exit, `false`: should continue and launch HMCL public static boolean processArguments(String[] args) { if (args.length != 2 || !args[0].equals("--monitor")) @@ -76,9 +72,9 @@ public static void start(Path specPath) throws IOException, InterruptedException LOG.info("*** HMCL MONITOR ***"); // read spec - ProcessSpec spec; + MonitorLauncher.MonitorLaunchSpec spec; try { - spec = JsonUtils.fromNonNullJson(Files.readString(specPath), ProcessSpec.class); + spec = JsonUtils.fromNonNullJson(Files.readString(specPath), MonitorLauncher.MonitorLaunchSpec.class); } catch (JsonParseException e) { throw new IOException("Malformed process spec file " + specPath, e); } @@ -157,36 +153,4 @@ public record Result(ManagedProcess process, ProcessListener.ExitType exitType, @Nullable String gameDirectoryId, @Nullable List commands, LaunchOptions launchOptions) { } - - /// The specification of the game process to supervise, exchanged with the monitor process as JSON. - /// - /// @param command The command line to execute, passed to [ProcessBuilder] verbatim. - /// @param directory The absolute working directory used for the game process and the post-exit command. - /// @param environment Environment variables overlaid on the monitor's environment for both commands. - /// @param encoding The name of the charset used to decode the game process output. - /// @param inheritStdin Whether the game process should inherit the monitor's standard input; set when no process - /// listener is present. - /// @param postExitCommand The post-exit command to run after the game exits, already tokenized, or `null`. - /// @param relaunchPolicy The policy for opening the launcher UI in the monitor JVM after the protocol stream fails. - /// @param gameDirectoryId The persistent id of the registered game directory owning the launched instance, or `null` - /// when unknown. - /// @param launchOptions The launch options retained for presenting the game crash window. - @NotNullByDefault - public record ProcessSpec(List command, String directory, Map environment, String encoding, - boolean inheritStdin, @Nullable List postExitCommand, - MonitorLauncher.RelaunchPolicy relaunchPolicy, @Nullable String gameDirectoryId, - LaunchOptions launchOptions) { - /// Creates a process specification from a launch context whose builder has a working directory. - public ProcessSpec(MonitorLauncher.MonitorLaunchContext context) { - this(new ArrayList<>(context.builder().command()), - context.builder().directory().getAbsolutePath(), - context.builder().environment(), - context.encoding().name(), - context.listener() == null, - context.postExitCommand(), - context.relaunchPolicy(), - context.gameDirectoryId(), - context.options()); - } - } } diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java index 304badf63da..5b97e64719f 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java @@ -42,7 +42,9 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; @@ -81,7 +83,7 @@ public static MonitorGameProcess launchNewMonitor(MonitorLaunchContext context) Path specFile = Files.createTempFile("hmcl-monitor-spec-", ".json"); try (var writer = Files.newBufferedWriter(specFile)) { - gsonForGameOptions.toJson(new Monitor.ProcessSpec(context), writer); + gsonForGameOptions.toJson(new MonitorLaunchSpec(context), writer); } List commandline = JavaProcessLauncher.buildJavaCommandLine(thisJar, @@ -201,4 +203,21 @@ public record MonitorLaunchContext( @Nullable List postExitCommand, RelaunchPolicy relaunchPolicy) { } -} + + @NotNullByDefault + public record MonitorLaunchSpec(List command, String directory, Map environment, String encoding, + boolean inheritStdin, @Nullable List postExitCommand, + RelaunchPolicy relaunchPolicy, @Nullable String gameDirectoryId, LaunchOptions launchOptions) { + public MonitorLaunchSpec(MonitorLaunchContext context) { + this(new ArrayList<>(context.builder().command()), + context.builder().directory().getAbsolutePath(), + context.builder().environment(), + context.encoding().name(), + context.listener() == null, + context.postExitCommand(), + context.relaunchPolicy(), + context.gameDirectoryId(), + context.options()); + } + } +} \ No newline at end of file From 78433015c8a2ece32306779b211d71c35316b1de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B1=BC=E5=89=8D=E4=B8=89=E7=99=BE?= Date: Wed, 21 Oct 2026 16:24:54 +0800 Subject: [PATCH 33/44] =?UTF-8?q?refactor:=20=E6=94=B6=E7=B4=A7=20Monitor?= =?UTF-8?q?=20=E7=BB=93=E6=9E=9C=E4=B8=8E=E5=90=AF=E5=8A=A8=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E7=9A=84=20gameDirectoryId=20=E9=9D=9E=E7=A9=BA?= =?UTF-8?q?=E7=BA=A6=E6=9D=9F=E5=B9=B6=E7=AE=80=E5=8C=96=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/jackhuang/hmcl/Launcher.java | 2 +- .../jackhuang/hmcl/game/HMCLGameLauncher.java | 34 ++++++++----------- .../hmcl/monitor/MonitorCrashReporter.java | 2 -- .../org/jackhuang/hmcl/monitor/Monitor.java | 20 +++++------ .../hmcl/monitor/MonitorLauncher.java | 12 +++---- 5 files changed, 30 insertions(+), 40 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java b/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java index 46915eb760c..fc734d01614 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java @@ -149,7 +149,7 @@ public void start(Stage primaryStage) { primaryStage.show(); // check whether to show log from HMCL monitor - if (Monitor.isCrashed()) + if (Monitor.wasCrashed()) MonitorCrashReporter.show(); }); } catch (Throwable e) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java index bec2c7af11c..00a2299d937 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java @@ -169,26 +169,20 @@ protected ProcessInfo startProcess(ProcessBuilder builder, Charset encoding, Pat if (!MonitorLauncher.isEnabled()) return super.startProcess(builder, encoding, nativeFolder); - String gameDirectoryId = null; - MonitorLauncher.RelaunchPolicy relaunchPolicy = MonitorLauncher.RelaunchPolicy.NEVER; - if (instance instanceof HMCLGameInstance hmclGameInstance) { - gameDirectoryId = hmclGameInstance.getRepository().getGameDirectory().getId().toString(); - - GameSettings.Effective setting = hmclGameInstance.getEffectiveSettings(); - LauncherVisibility x = setting.getInheritable(GameSettings::launcherVisibilityProperty); - boolean showLogs = setting.getInheritable(GameSettings::showLogsProperty); - relaunchPolicy = showLogs - ? MonitorLauncher.RelaunchPolicy.NEVER - : switch (x) { - case HIDE_AND_REOPEN -> MonitorLauncher.RelaunchPolicy.ALWAYS; - case HIDE -> MonitorLauncher.RelaunchPolicy.ON_CRASH; - default -> MonitorLauncher.RelaunchPolicy.NEVER; - }; - } - - List postExitCommand = null; - if (StringUtils.isNotBlank(options.getPostExitCommand())) - postExitCommand = StringUtils.tokenize(options.getPostExitCommand(), getEnvVars(nativeFolder)); + var hmclGameInstance = (HMCLGameInstance) instance; + String gameDirectoryId = hmclGameInstance.getRepository().getGameDirectory().getId().toString(); + + GameSettings.Effective setting = hmclGameInstance.getEffectiveSettings(); + LauncherVisibility x = setting.getInheritable(GameSettings::launcherVisibilityProperty); + boolean showLogs = setting.getInheritable(GameSettings::showLogsProperty); + MonitorLauncher.RelaunchPolicy relaunchPolicy = showLogs + ? MonitorLauncher.RelaunchPolicy.NEVER + : switch (x) { + case HIDE_AND_REOPEN -> MonitorLauncher.RelaunchPolicy.ALWAYS; + case HIDE -> MonitorLauncher.RelaunchPolicy.ON_CRASH; + default -> MonitorLauncher.RelaunchPolicy.NEVER; + }; + List postExitCommand = StringUtils.tokenize(options.getPostExitCommand(), getEnvVars(nativeFolder)); return MonitorLauncher.launchNewMonitor(new MonitorLauncher.MonitorLaunchContext(builder, listener, encoding, options, gameDirectoryId, postExitCommand, relaunchPolicy)); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java index a32470d58be..b0ed1eee948 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java @@ -46,8 +46,6 @@ private MonitorCrashReporter() { public static void show() { Monitor.Result result = Monitor.getResult(); - if (result == null || result.gameDirectoryId() == null) - return; // find repository GameDirectoryID id = GameDirectoryID.parse(result.gameDirectoryId()); diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java index 8a892d294b8..648c82edaa8 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java @@ -34,6 +34,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.Objects; import java.util.function.BiConsumer; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -41,22 +42,22 @@ @NotNullByDefault public final class Monitor { public static final String CANCELED = "CANCELED"; - private static boolean shouldRestart = false; + private static boolean shouldRelaunch = false; private static boolean crashed = false; private static @Nullable Monitor.Result result; private Monitor() { } - public static @Nullable Monitor.Result getResult() { - return result; + public static Monitor.Result getResult() throws NullPointerException { + return Objects.requireNonNull(result); } - public static boolean isCrashed() { + public static boolean wasCrashed() { return crashed; } - /// @return `true`: should exit, `false`: should continue and launch HMCL + /// @return `true` if should exit, or `false` if should continue and launch HMCL public static boolean processArguments(String[] args) { if (args.length != 2 || !args[0].equals("--monitor")) return false; @@ -65,7 +66,7 @@ public static boolean processArguments(String[] args) { } catch (IOException | InterruptedException e) { LOG.error("Failed to supervise the game process", e); } - return !shouldRestart; + return !shouldRelaunch; } public static void start(Path specPath) throws IOException, InterruptedException { @@ -118,11 +119,12 @@ public static void start(Path specPath) throws IOException, InterruptedException MonitorProtocol.exitMessage(exitCode, reportedType); crashed = reportedType != ProcessListener.ExitType.NORMAL; - shouldRestart = MonitorProtocol.parentGone() && switch (spec.relaunchPolicy()) { + shouldRelaunch = MonitorProtocol.parentGone() && switch (spec.relaunchPolicy()) { case ALWAYS -> true; case ON_CRASH -> crashed; case NEVER -> false; }; + result = new Result(gameProcess, reportedType, spec.gameDirectoryId(), spec.command(), spec.launchOptions()); // run post exit command if (spec.postExitCommand() != null && !spec.postExitCommand().isEmpty()) { @@ -134,8 +136,6 @@ public static void start(Path specPath) throws IOException, InterruptedException LOG.warning("An Exception happened while running exit command.", e); } } - - result = new Result(gameProcess, reportedType, spec.gameDirectoryId(), spec.command(), spec.launchOptions()); }), "exit-waiter", true).join(); } @@ -150,7 +150,7 @@ public static void start(Path specPath) throws IOException, InterruptedException /// @param launchOptions The launch options used to identify the instance and present its crash window. @NotNullByDefault public record Result(ManagedProcess process, ProcessListener.ExitType exitType, - @Nullable String gameDirectoryId, @Nullable List commands, + String gameDirectoryId, @Nullable List commands, LaunchOptions launchOptions) { } } diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java index 5b97e64719f..3284682b22d 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java @@ -64,8 +64,7 @@ public boolean shouldSkipField(FieldAttributes f) { @Override public boolean shouldSkipClass(Class clazz) { - return ProxyOption.class.isAssignableFrom(clazz) - || Renderer.class.isAssignableFrom(clazz); // ignoring these + return ProxyOption.class.isAssignableFrom(clazz) || Renderer.class.isAssignableFrom(clazz); } }).create(); @@ -90,8 +89,7 @@ public static MonitorGameProcess launchNewMonitor(MonitorLaunchContext context) "--monitor", specFile.toAbsolutePath().toString()); LOG.info("Starting monitor process: " + JavaProcessLauncher.maskCommandLine(commandline)); - Process monitor = new ProcessBuilder(commandline) - .directory(Paths.get("").toAbsolutePath().toFile()) + Process monitor = new ProcessBuilder(commandline).directory(Paths.get("").toAbsolutePath().toFile()) .redirectOutput(ProcessBuilder.Redirect.INHERIT) .redirectError(ProcessBuilder.Redirect.PIPE) .start(); @@ -199,15 +197,15 @@ public record MonitorLaunchContext( @Nullable ProcessListener listener, Charset encoding, LaunchOptions options, - @Nullable String gameDirectoryId, - @Nullable List postExitCommand, + String gameDirectoryId, + List postExitCommand, RelaunchPolicy relaunchPolicy) { } @NotNullByDefault public record MonitorLaunchSpec(List command, String directory, Map environment, String encoding, boolean inheritStdin, @Nullable List postExitCommand, - RelaunchPolicy relaunchPolicy, @Nullable String gameDirectoryId, LaunchOptions launchOptions) { + RelaunchPolicy relaunchPolicy, String gameDirectoryId, LaunchOptions launchOptions) { public MonitorLaunchSpec(MonitorLaunchContext context) { this(new ArrayList<>(context.builder().command()), context.builder().directory().getAbsolutePath(), From ca0be5c480896c8c95f3ef1bd77f18ab43f58e7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B1=BC=E5=89=8D=E4=B8=89=E7=99=BE?= Date: Wed, 21 Oct 2026 16:28:07 +0800 Subject: [PATCH 34/44] =?UTF-8?q?style:=20=E6=B7=BB=E5=8A=A0=E7=A9=BA?= =?UTF-8?q?=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/jackhuang/hmcl/monitor/MonitorLauncher.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java index 3284682b22d..8934f7973ca 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java @@ -203,8 +203,8 @@ public record MonitorLaunchContext( } @NotNullByDefault - public record MonitorLaunchSpec(List command, String directory, Map environment, String encoding, - boolean inheritStdin, @Nullable List postExitCommand, + public record MonitorLaunchSpec(List command, String directory, Map environment, + String encoding, boolean inheritStdin, @Nullable List postExitCommand, RelaunchPolicy relaunchPolicy, String gameDirectoryId, LaunchOptions launchOptions) { public MonitorLaunchSpec(MonitorLaunchContext context) { this(new ArrayList<>(context.builder().command()), @@ -218,4 +218,4 @@ public MonitorLaunchSpec(MonitorLaunchContext context) { context.options()); } } -} \ No newline at end of file +} From c1890189d55a75b781ea63a686d90929198bcb23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B1=BC=E5=89=8D=E4=B8=89=E7=99=BE?= Date: Wed, 21 Oct 2026 17:20:31 +0800 Subject: [PATCH 35/44] =?UTF-8?q?refactor:=20=E7=A7=BB=E9=99=A4=E6=97=A0?= =?UTF-8?q?=E7=94=A8=E7=9A=84=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/jackhuang/hmcl/monitor/Monitor.java | 16 +++++----------- .../jackhuang/hmcl/monitor/MonitorLauncher.java | 2 +- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java index 648c82edaa8..733699b595f 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java @@ -124,10 +124,10 @@ public static void start(Path specPath) throws IOException, InterruptedException case ON_CRASH -> crashed; case NEVER -> false; }; - result = new Result(gameProcess, reportedType, spec.gameDirectoryId(), spec.command(), spec.launchOptions()); + result = new Result(gameProcess, reportedType, spec.gameDirectoryId(), spec.launchOptions()); // run post exit command - if (spec.postExitCommand() != null && !spec.postExitCommand().isEmpty()) { + if (!spec.postExitCommand().isEmpty()) try { ProcessBuilder builder = new ProcessBuilder(spec.postExitCommand()).directory(spec.launchOptions().getGameDir().toFile()); builder.environment().putAll(spec.environment()); @@ -135,22 +135,16 @@ public static void start(Path specPath) throws IOException, InterruptedException } catch (Throwable e) { LOG.warning("An Exception happened while running exit command.", e); } - } }), "exit-waiter", true).join(); } - /// The in-memory result of a supervised game process, retained when the monitor JVM continues - /// into the launcher UI to present the game crash window. + /// launch result of the latest game process /// /// @param process The supervised game process, which has already exited. /// @param exitType The reported exit type, including `INTERRUPTED` for a recognized cancellation. - /// @param gameDirectoryId The persistent id of the registered game directory owning the launched - /// instance, or `null` when unknown. - /// @param commands The command line of the game process, or `null` when unknown. + /// @param gameDirectoryId The persistent id of the registered game directory owning the launched instance. /// @param launchOptions The launch options used to identify the instance and present its crash window. @NotNullByDefault - public record Result(ManagedProcess process, ProcessListener.ExitType exitType, - String gameDirectoryId, @Nullable List commands, - LaunchOptions launchOptions) { + public record Result(ManagedProcess process, ProcessListener.ExitType exitType, String gameDirectoryId, LaunchOptions launchOptions) { } } diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java index 8934f7973ca..db9eb989699 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java @@ -204,7 +204,7 @@ public record MonitorLaunchContext( @NotNullByDefault public record MonitorLaunchSpec(List command, String directory, Map environment, - String encoding, boolean inheritStdin, @Nullable List postExitCommand, + String encoding, boolean inheritStdin, List postExitCommand, RelaunchPolicy relaunchPolicy, String gameDirectoryId, LaunchOptions launchOptions) { public MonitorLaunchSpec(MonitorLaunchContext context) { this(new ArrayList<>(context.builder().command()), From fb9d9fb4b39c7e3c0fbec91b22339b08322e8ae9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B1=BC=E5=89=8D=E4=B8=89=E7=99=BE?= Date: Wed, 21 Oct 2026 17:34:28 +0800 Subject: [PATCH 36/44] =?UTF-8?q?docs:=20=E4=BC=98=E5=8C=96=E6=B3=A8?= =?UTF-8?q?=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/jackhuang/hmcl/monitor/Monitor.java | 4 +- .../hmcl/monitor/MonitorLauncher.java | 37 ++++++------------- 2 files changed, 14 insertions(+), 27 deletions(-) diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java index 733699b595f..52596d70d15 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java @@ -124,12 +124,12 @@ public static void start(Path specPath) throws IOException, InterruptedException case ON_CRASH -> crashed; case NEVER -> false; }; - result = new Result(gameProcess, reportedType, spec.gameDirectoryId(), spec.launchOptions()); + result = new Result(gameProcess, reportedType, spec.gameDirectoryId(), spec.options()); // run post exit command if (!spec.postExitCommand().isEmpty()) try { - ProcessBuilder builder = new ProcessBuilder(spec.postExitCommand()).directory(spec.launchOptions().getGameDir().toFile()); + ProcessBuilder builder = new ProcessBuilder(spec.postExitCommand()).directory(spec.options().getGameDir().toFile()); builder.environment().putAll(spec.environment()); SystemUtils.callExternalProcess(builder); } catch (Throwable e) { diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java index db9eb989699..d71d9d383fe 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java @@ -42,7 +42,6 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -142,10 +141,7 @@ else if (exit.exitType() != ProcessListener.ExitType.NORMAL) } } - /// Reads the handshake message `P\t\t` from the monitor. - /// - /// @throws IOException if the monitor exits or fails before the handshake, sends a malformed - /// message, or does not answer within [MonitorProtocol#HANDSHAKE_TIMEOUT] + /// @throws IOException if launcher cannot shake hand with monitor within [MonitorProtocol#HANDSHAKE_TIMEOUT] private static MonitorProtocol.Pid readHandshake(Process monitor, BufferedReader reader) throws IOException { CompletableFuture future = new CompletableFuture<>(); Thread waiter = Lang.thread(() -> { @@ -192,30 +188,21 @@ public enum RelaunchPolicy { ALWAYS } - public record MonitorLaunchContext( - ProcessBuilder builder, - @Nullable ProcessListener listener, - Charset encoding, - LaunchOptions options, - String gameDirectoryId, - List postExitCommand, - RelaunchPolicy relaunchPolicy) { + public record MonitorLaunchContext(ProcessBuilder builder, @Nullable ProcessListener listener, Charset encoding, + LaunchOptions options, String gameDirectoryId, List postExitCommand, + RelaunchPolicy relaunchPolicy) { } - @NotNullByDefault public record MonitorLaunchSpec(List command, String directory, Map environment, - String encoding, boolean inheritStdin, List postExitCommand, - RelaunchPolicy relaunchPolicy, String gameDirectoryId, LaunchOptions launchOptions) { + boolean inheritStdin, String encoding, LaunchOptions options, + String gameDirectoryId, List postExitCommand, + RelaunchPolicy relaunchPolicy) { public MonitorLaunchSpec(MonitorLaunchContext context) { - this(new ArrayList<>(context.builder().command()), - context.builder().directory().getAbsolutePath(), - context.builder().environment(), - context.encoding().name(), - context.listener() == null, - context.postExitCommand(), - context.relaunchPolicy(), - context.gameDirectoryId(), - context.options()); + this(context.builder().command(), context.builder().directory().getAbsolutePath(), context.builder().environment(), + context.listener() == null, context.encoding().name(), + context.options(), context.gameDirectoryId(), context.postExitCommand(), + context.relaunchPolicy() + ); } } } From 10470893f407e17e816f70f8da32fefb9635bdd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B1=BC=E5=89=8D=E4=B8=89=E7=99=BE?= Date: Wed, 21 Oct 2026 22:35:22 +0800 Subject: [PATCH 37/44] =?UTF-8?q?refactor:=20=E4=BD=BF=E7=94=A8=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E5=8C=B9=E9=85=8D=E7=AE=80=E5=8C=96=20HMCLGameInstanc?= =?UTF-8?q?e=20=E7=B1=BB=E5=9E=8B=E5=88=A4=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java index 00a2299d937..90fa2ac313c 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java @@ -166,12 +166,10 @@ public ProcessInfo launch() throws IOException, InterruptedException { @Override protected ProcessInfo startProcess(ProcessBuilder builder, Charset encoding, Path nativeFolder) throws IOException { - if (!MonitorLauncher.isEnabled()) + if (!MonitorLauncher.isEnabled() || !(instance instanceof HMCLGameInstance hmclGameInstance)) return super.startProcess(builder, encoding, nativeFolder); - var hmclGameInstance = (HMCLGameInstance) instance; String gameDirectoryId = hmclGameInstance.getRepository().getGameDirectory().getId().toString(); - GameSettings.Effective setting = hmclGameInstance.getEffectiveSettings(); LauncherVisibility x = setting.getInheritable(GameSettings::launcherVisibilityProperty); boolean showLogs = setting.getInheritable(GameSettings::showLogsProperty); From 774c006acb8b3d9e03aa8dc7aa6c3a30738270b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B1=BC=E5=89=8D=E4=B8=89=E7=99=BE?= Date: Thu, 22 Oct 2026 12:09:36 +0800 Subject: [PATCH 38/44] =?UTF-8?q?refactor:=20=E5=B0=86=20MonitorLauncher?= =?UTF-8?q?=20=E7=A7=BB=E8=87=B3=20HMCL=20=E5=B9=B6=E7=AE=80=E5=8C=96?= =?UTF-8?q?=E7=9B=91=E6=8E=A7=E5=90=AF=E5=8A=A8=E4=B8=8E=E5=8D=8F=E8=AE=AE?= =?UTF-8?q?=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jackhuang/hmcl/game/HMCLGameLauncher.java | 26 +-- .../jackhuang/hmcl/game/LauncherHelper.java | 4 +- .../hmcl/monitor/MonitorLauncher.java | 169 ++++++++++++++ .../jackhuang/hmcl/upgrade/UpdateHandler.java | 2 +- .../hmcl/launch/DefaultLauncher.java | 25 +-- .../util/platform/JavaProcessLauncher.java | 16 +- .../org/jackhuang/hmcl/monitor/Monitor.java | 49 +++-- .../hmcl/monitor/MonitorLauncher.java | 208 ------------------ .../hmcl/monitor/MonitorProtocol.java | 89 ++++++-- 9 files changed, 293 insertions(+), 295 deletions(-) create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java delete mode 100644 HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java index 90fa2ac313c..7dd24dcf801 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java @@ -22,10 +22,7 @@ import org.jackhuang.hmcl.launch.DefaultLauncher; import org.jackhuang.hmcl.launch.ProcessListener; import org.jackhuang.hmcl.monitor.MonitorLauncher; -import org.jackhuang.hmcl.setting.GameSettings; -import org.jackhuang.hmcl.setting.LauncherVisibility; import org.jackhuang.hmcl.util.NativePatcher; -import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.i18n.LocaleUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.io.JarUtils; @@ -165,25 +162,10 @@ public ProcessInfo launch() throws IOException, InterruptedException { } @Override - protected ProcessInfo startProcess(ProcessBuilder builder, Charset encoding, Path nativeFolder) throws IOException { - if (!MonitorLauncher.isEnabled() || !(instance instanceof HMCLGameInstance hmclGameInstance)) - return super.startProcess(builder, encoding, nativeFolder); - - String gameDirectoryId = hmclGameInstance.getRepository().getGameDirectory().getId().toString(); - GameSettings.Effective setting = hmclGameInstance.getEffectiveSettings(); - LauncherVisibility x = setting.getInheritable(GameSettings::launcherVisibilityProperty); - boolean showLogs = setting.getInheritable(GameSettings::showLogsProperty); - MonitorLauncher.RelaunchPolicy relaunchPolicy = showLogs - ? MonitorLauncher.RelaunchPolicy.NEVER - : switch (x) { - case HIDE_AND_REOPEN -> MonitorLauncher.RelaunchPolicy.ALWAYS; - case HIDE -> MonitorLauncher.RelaunchPolicy.ON_CRASH; - default -> MonitorLauncher.RelaunchPolicy.NEVER; - }; - List postExitCommand = StringUtils.tokenize(options.getPostExitCommand(), getEnvVars(nativeFolder)); - - return MonitorLauncher.launchNewMonitor(new MonitorLauncher.MonitorLaunchContext(builder, listener, - encoding, options, gameDirectoryId, postExitCommand, relaunchPolicy)); + protected ProcessInfo startProcess(List commandLine, Map envVars, Charset encoding) throws IOException { + if (!MonitorLauncher.MONITOR_ENABLED || !(instance instanceof HMCLGameInstance hmclGameInstance)) + return super.startProcess(commandLine, envVars, encoding); + return MonitorLauncher.launchNewMonitor(commandLine, envVars, encoding, listener, hmclGameInstance, options); } @Override diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java index df7a3a7a041..279e15fcf31 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -994,9 +994,7 @@ public void onLog(String log, boolean isErrorStream) { try { if (!lwjgl) { lwjgl = true; - if (MonitorLauncher.isEnabled() && !showLogs - && (launcherVisibility == LauncherVisibility.HIDE - || launcherVisibility == LauncherVisibility.HIDE_AND_REOPEN)) { + if (MonitorLauncher.MONITOR_ENABLED && !showLogs && launcherVisibility != LauncherVisibility.KEEP) { launchingLatch.countDown(); runLater(Launcher::stopApplication); } else { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java new file mode 100644 index 00000000000..bcaafd2b3f6 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java @@ -0,0 +1,169 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui 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 . + */ +package org.jackhuang.hmcl.monitor; + +import com.google.gson.ExclusionStrategy; +import com.google.gson.FieldAttributes; +import com.google.gson.Gson; +import org.jackhuang.hmcl.event.EventBus; +import org.jackhuang.hmcl.event.JVMLaunchFailedEvent; +import org.jackhuang.hmcl.event.ProcessExitedAbnormallyEvent; +import org.jackhuang.hmcl.event.ProcessStoppedEvent; +import org.jackhuang.hmcl.game.HMCLGameInstance; +import org.jackhuang.hmcl.game.LaunchOptions; +import org.jackhuang.hmcl.game.ProxyOption; +import org.jackhuang.hmcl.game.Renderer; +import org.jackhuang.hmcl.launch.ProcessListener; +import org.jackhuang.hmcl.setting.GameSettings; +import org.jackhuang.hmcl.setting.LauncherVisibility; +import org.jackhuang.hmcl.util.Lang; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.gson.*; +import org.jackhuang.hmcl.util.io.JarUtils; +import org.jackhuang.hmcl.util.platform.JavaProcessLauncher; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +@NotNullByDefault +public final class MonitorLauncher { + public static final boolean MONITOR_ENABLED = System.getenv("HMCL_USE_HMCLMONITOR") != null; + private static final Gson gsonForGameOptions = JsonUtils.defaultGsonBuilder() + .addSerializationExclusionStrategy(new ExclusionStrategy() { + @Override + public boolean shouldSkipField(FieldAttributes f) { + return false; + } + + @Override + public boolean shouldSkipClass(Class clazz) { + return ProxyOption.class.isAssignableFrom(clazz) || Renderer.class.isAssignableFrom(clazz); + } + }).create(); + + private MonitorLauncher() { + } + + public static MonitorGameProcess launchNewMonitor(List commandLine, + Map envVars, + Charset encoding, + @Nullable ProcessListener listener, + HMCLGameInstance hmclGameInstance, + LaunchOptions options) throws IOException { + Path specFile = writeSpecFile(commandLine, envVars, encoding, listener, hmclGameInstance, options); + return startProcess(commandLine, listener, specFile); + } + + private static Path writeSpecFile(List commandLine, + Map envVars, + Charset encoding, + @Nullable ProcessListener listener, + HMCLGameInstance hmclGameInstance, + LaunchOptions options) throws IOException { + Path runDirectory = hmclGameInstance.getRunDirectory().toAbsolutePath(); + String gameDirectoryId = hmclGameInstance.getRepository().getGameDirectory().getId().toString(); + + Map gameEnv = new HashMap<>(); + Path appdata = options.getGameDir().toAbsolutePath().getParent(); + if (appdata != null) gameEnv.put("APPDATA", appdata.toString()); + gameEnv.putAll(envVars); + + List postExitCommand = StringUtils.tokenize(options.getPostExitCommand(), envVars); + + GameSettings.Effective setting = hmclGameInstance.getEffectiveSettings(); + LauncherVisibility x = setting.getInheritable(GameSettings::launcherVisibilityProperty); + boolean showLogs = setting.getInheritable(GameSettings::showLogsProperty); + Monitor.RelaunchPolicy relaunchPolicy = showLogs + ? Monitor.RelaunchPolicy.NEVER + : switch (x) { + case HIDE_AND_REOPEN -> Monitor.RelaunchPolicy.ALWAYS; + case HIDE -> Monitor.RelaunchPolicy.ON_CRASH; + default -> Monitor.RelaunchPolicy.NEVER; + }; + + Path specFile = Files.createTempFile("hmcl-monitor-spec-", ".json"); + try (var writer = Files.newBufferedWriter(specFile)) { + gsonForGameOptions.toJson(new Monitor.MonitorLaunchSpec( + commandLine, + runDirectory, + gameEnv, + listener == null, + encoding.name(), + options, + gameDirectoryId, + postExitCommand, + relaunchPolicy + ), writer); + } + return specFile; + } + + private static MonitorGameProcess startProcess(List commandLine, @Nullable ProcessListener listener, Path specFile) throws IOException { + Path thisJar = JarUtils.thisJarPath(); + if (thisJar == null) + throw new IOException("Failed to find current HMCL location"); + + Process monitor = JavaProcessLauncher.buildJava(thisJar, "--monitor", specFile.toAbsolutePath().toString()) + .redirectOutput(ProcessBuilder.Redirect.INHERIT) + .redirectError(ProcessBuilder.Redirect.PIPE).start(); + + BufferedReader reader = new BufferedReader(new InputStreamReader(monitor.getErrorStream(), MonitorProtocol.CHARSET)); + + MonitorProtocol.Pid handshake = MonitorProtocol.readHandshake(reader, monitor); + ProcessHandle gameHandle = ProcessHandle.of(handshake.pid()).orElse(null); + MonitorGameProcess process = new MonitorGameProcess(gameHandle, commandLine, handshake.processStartTime(), specFile); + + if (listener != null) + listener.setProcess(process); + + Lang.thread(() -> { + try { + MonitorProtocol.readAll(reader, msg -> { + if (listener == null) + return; + + if (msg instanceof MonitorProtocol.Log log) { + listener.onLog(log.line(), log.isErrorStream()); + } else if (msg instanceof MonitorProtocol.Exit exit) { + if (exit.exitType() == ProcessListener.ExitType.JVM_ERROR) + EventBus.EVENT_BUS.fireEvent(new JVMLaunchFailedEvent(MonitorLauncher.class, process)); + else if (exit.exitType() != ProcessListener.ExitType.NORMAL) + EventBus.EVENT_BUS.fireEvent(new ProcessExitedAbnormallyEvent(MonitorLauncher.class, process)); + EventBus.EVENT_BUS.fireEvent(new ProcessStoppedEvent(MonitorLauncher.class, process)); + + listener.onExit(exit.exitCode(), exit.exitType()); + } + }, (line, e) -> LOG.warning("Unexpected message from HMCL monitor: " + line, e)); + } catch (IOException e) { + LOG.error("Failed to read from the HMCL monitor", e); + } + }, "hmcl-monitor-reader", true); + return process; + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateHandler.java b/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateHandler.java index e6d80b9a0e4..c38fdbededf 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateHandler.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateHandler.java @@ -196,7 +196,7 @@ private static void requestUpdate(Path updateTo, Path self) throws IOException { } public static void startJava(Path jar, String... appArgs) throws IOException { - JavaProcessLauncher.startJava(jar, appArgs); + JavaProcessLauncher.buildJava(jar, appArgs).inheritIO().start(); } private static Optional tryRename(Path path, String newVersion) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java index f6258b35115..8151f1d37f6 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java @@ -695,30 +695,29 @@ public ProcessInfo launch() throws IOException, InterruptedException { } try { - ProcessBuilder builder = new ProcessBuilder(rawCommandLine).directory(runDirectory.toFile()); - if (listener == null) { - builder.inheritIO(); - } - Path appdata = options.getGameDir().toAbsolutePath().getParent(); - if (appdata != null) builder.environment().put("APPDATA", appdata.toString()); - - builder.environment().putAll(getEnvVars(nativeFolder)); - - return startProcess(builder, command.encoding, nativeFolder); + return startProcess(rawCommandLine, getEnvVars(nativeFolder), command.encoding); } catch (IOException e) { throw new ProcessCreationException(e); } } - protected ProcessInfo startProcess(ProcessBuilder builder, Charset encoding, Path nativeFolder) throws IOException { + protected ProcessInfo startProcess(List commandLine, Map envVars, Charset encoding) throws IOException { + ProcessBuilder builder = new ProcessBuilder(commandLine).directory(instance.getRunDirectory().toFile()); + if (listener == null) { + builder.inheritIO(); + } + Path appdata = options.getGameDir().toAbsolutePath().getParent(); + if (appdata != null) builder.environment().put("APPDATA", appdata.toString()); + builder.environment().putAll(envVars); + Process process = builder.start(); ManagedProcess p = new ManagedProcess(process, builder.command()); if (listener != null) - startMonitors(p, nativeFolder, listener, encoding, daemon); + startMonitors(p, getNativeFolder(), listener, encoding, daemon); return p; } - protected Map getEnvVars(Path nativeFolder) { + private Map getEnvVars(Path nativeFolder) { String versionName = Optional.ofNullable(options.getVersionName()).orElse(manifest.id().toString()); Map env = new LinkedHashMap<>(); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/JavaProcessLauncher.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/JavaProcessLauncher.java index e083e4f7893..4c7c40c7c50 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/JavaProcessLauncher.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/JavaProcessLauncher.java @@ -20,7 +20,6 @@ import org.jackhuang.hmcl.java.JavaRuntime; import org.jetbrains.annotations.NotNullByDefault; -import java.io.IOException; import java.lang.management.ManagementFactory; import java.nio.file.Path; import java.nio.file.Paths; @@ -97,20 +96,9 @@ public static String maskCommandLine(List commandline) { }).collect(Collectors.joining(" ")); } - /// Starts a new JVM process running the given jar with the given arguments. - /// - ///

    Fire-and-forget: the process inherits this process's I/O and is never waited for. The - /// masked command line is written to the log before the process is started. - /// - /// @param jar the jar file to run - /// @param appArgs the application arguments passed to the new process - /// @throws IOException if the process cannot be created - public static void startJava(Path jar, String... appArgs) throws IOException { + public static ProcessBuilder buildJava(Path jar, String... appArgs) { List commandline = buildJavaCommandLine(jar, appArgs); LOG.info("Starting process: " + maskCommandLine(commandline)); - new ProcessBuilder(commandline) - .directory(Paths.get("").toAbsolutePath().toFile()) - .inheritIO() - .start(); + return new ProcessBuilder(commandline).directory(Paths.get("").toAbsolutePath().toFile()); } } diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java index 52596d70d15..31a1be88b8a 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java @@ -34,6 +34,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.function.BiConsumer; @@ -73,29 +74,34 @@ public static void start(Path specPath) throws IOException, InterruptedException LOG.info("*** HMCL MONITOR ***"); // read spec - MonitorLauncher.MonitorLaunchSpec spec; + MonitorLaunchSpec spec; try { - spec = JsonUtils.fromNonNullJson(Files.readString(specPath), MonitorLauncher.MonitorLaunchSpec.class); + spec = JsonUtils.fromNonNullJson(Files.readString(specPath), MonitorLaunchSpec.class); } catch (JsonParseException e) { throw new IOException("Malformed process spec file " + specPath, e); } Files.deleteIfExists(specPath); // start game process - ProcessBuilder pb = new ProcessBuilder(spec.command()).directory(new File(spec.directory())); - pb.environment().putAll(spec.environment()); - if (spec.inheritStdin()) + ProcessBuilder pb = new ProcessBuilder(spec.command).directory(spec.directory.toFile()); + pb.environment().putAll(spec.environment); + if (spec.inheritStdin) pb.redirectInput(ProcessBuilder.Redirect.INHERIT); - ManagedProcess gameProcess = new ManagedProcess(pb.start(), spec.command()); + ManagedProcess gameProcess = new ManagedProcess(pb.start(), spec.command); LOG.info("Game process created, pid " + gameProcess.getPid() + ", spec " + specPath); - MonitorProtocol.pidMessage(gameProcess.getPid(), gameProcess.getProcessStartTime()); + // shake hand + PrintStream protocol = new PrintStream(new FileOutputStream(FileDescriptor.err), true, MonitorProtocol.CHARSET); + MonitorProtocol.pidMessage(protocol, gameProcess.getPid(), gameProcess.getProcessStartTime()); + + // pump game io BiConsumer sink = (line, isErrorStream) -> { gameProcess.addLine(line); - MonitorProtocol.logMessage(isErrorStream, line); + MonitorProtocol.logMessage(protocol, isErrorStream, line); }; - Charset encoding = Charset.forName(spec.encoding()); + + Charset encoding = Charset.forName(spec.encoding); Thread stdoutPump = Lang.thread(new StreamPump(gameProcess.getProcess().getInputStream(), line -> sink.accept(line, false), encoding), "monitor-stdout-pump", true); Thread stderrPump = Lang.thread(new StreamPump(gameProcess.getProcess().getErrorStream(), @@ -116,20 +122,20 @@ public static void start(Path specPath) throws IOException, InterruptedException } catch (IOException e1) { LOG.warning("Failed to check whether to cancel " + specPath, e1); } - MonitorProtocol.exitMessage(exitCode, reportedType); + MonitorProtocol.exitMessage(protocol, exitCode, reportedType); crashed = reportedType != ProcessListener.ExitType.NORMAL; - shouldRelaunch = MonitorProtocol.parentGone() && switch (spec.relaunchPolicy()) { + shouldRelaunch = protocol.checkError() && switch (spec.relaunchPolicy) { case ALWAYS -> true; case ON_CRASH -> crashed; case NEVER -> false; }; - result = new Result(gameProcess, reportedType, spec.gameDirectoryId(), spec.options()); + result = new Result(gameProcess, reportedType, spec.gameDirectoryId, spec.options); // run post exit command - if (!spec.postExitCommand().isEmpty()) + if (!spec.postExitCommand.isEmpty()) try { - ProcessBuilder builder = new ProcessBuilder(spec.postExitCommand()).directory(spec.options().getGameDir().toFile()); + ProcessBuilder builder = new ProcessBuilder(spec.postExitCommand).directory(spec.options.getGameDir().toFile()); builder.environment().putAll(spec.environment()); SystemUtils.callExternalProcess(builder); } catch (Throwable e) { @@ -138,6 +144,15 @@ public static void start(Path specPath) throws IOException, InterruptedException }), "exit-waiter", true).join(); } + public enum RelaunchPolicy { + /// Never continue into the launcher UI after the game exits. + NEVER, + /// Continue into the launcher UI after a non-normal exit if the protocol stream has failed. + ON_CRASH, + /// Continue into the launcher UI after any exit if the protocol stream has failed. + ALWAYS + } + /// launch result of the latest game process /// /// @param process The supervised game process, which has already exited. @@ -147,4 +162,10 @@ public static void start(Path specPath) throws IOException, InterruptedException @NotNullByDefault public record Result(ManagedProcess process, ProcessListener.ExitType exitType, String gameDirectoryId, LaunchOptions launchOptions) { } + + public record MonitorLaunchSpec(List command, Path directory, Map environment, + boolean inheritStdin, String encoding, LaunchOptions options, + String gameDirectoryId, List postExitCommand, + RelaunchPolicy relaunchPolicy) { + } } diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java deleted file mode 100644 index d71d9d383fe..00000000000 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java +++ /dev/null @@ -1,208 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2026 huangyuhui 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 . - */ -package org.jackhuang.hmcl.monitor; - -import com.google.gson.ExclusionStrategy; -import com.google.gson.FieldAttributes; -import com.google.gson.Gson; -import org.jackhuang.hmcl.event.EventBus; -import org.jackhuang.hmcl.event.JVMLaunchFailedEvent; -import org.jackhuang.hmcl.event.ProcessExitedAbnormallyEvent; -import org.jackhuang.hmcl.event.ProcessStoppedEvent; -import org.jackhuang.hmcl.game.LaunchOptions; -import org.jackhuang.hmcl.game.ProxyOption; -import org.jackhuang.hmcl.game.Renderer; -import org.jackhuang.hmcl.launch.ProcessListener; -import org.jackhuang.hmcl.util.Lang; -import org.jackhuang.hmcl.util.gson.*; -import org.jackhuang.hmcl.util.io.JarUtils; -import org.jackhuang.hmcl.util.platform.JavaProcessLauncher; -import org.jetbrains.annotations.NotNullByDefault; -import org.jetbrains.annotations.Nullable; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; -import java.nio.charset.Charset; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; - -import static org.jackhuang.hmcl.util.logging.Logger.LOG; - -@NotNullByDefault -public final class MonitorLauncher { - private static final boolean MONITOR_ENABLED = System.getenv("HMCL_USE_HMCLMONITOR") != null; - private static final Gson gsonForGameOptions = JsonUtils.defaultGsonBuilder() - .addSerializationExclusionStrategy(new ExclusionStrategy() { - @Override - public boolean shouldSkipField(FieldAttributes f) { - return false; - } - - @Override - public boolean shouldSkipClass(Class clazz) { - return ProxyOption.class.isAssignableFrom(clazz) || Renderer.class.isAssignableFrom(clazz); - } - }).create(); - - private MonitorLauncher() { - } - - public static boolean isEnabled() { - return MONITOR_ENABLED; - } - - public static MonitorGameProcess launchNewMonitor(MonitorLaunchContext context) throws IOException { - Path thisJar = JarUtils.thisJarPath(); - if (thisJar == null) - throw new IOException("Failed to find current HMCL location"); - - Path specFile = Files.createTempFile("hmcl-monitor-spec-", ".json"); - try (var writer = Files.newBufferedWriter(specFile)) { - gsonForGameOptions.toJson(new MonitorLaunchSpec(context), writer); - } - - List commandline = JavaProcessLauncher.buildJavaCommandLine(thisJar, - "--monitor", specFile.toAbsolutePath().toString()); - LOG.info("Starting monitor process: " + JavaProcessLauncher.maskCommandLine(commandline)); - - Process monitor = new ProcessBuilder(commandline).directory(Paths.get("").toAbsolutePath().toFile()) - .redirectOutput(ProcessBuilder.Redirect.INHERIT) - .redirectError(ProcessBuilder.Redirect.PIPE) - .start(); - - BufferedReader reader = new BufferedReader(new InputStreamReader(monitor.getErrorStream(), MonitorProtocol.CHARSET)); - - MonitorProtocol.Pid handshake = readHandshake(monitor, reader); - ProcessHandle gameHandle = ProcessHandle.of(handshake.pid()).orElse(null); - MonitorGameProcess process = new MonitorGameProcess(gameHandle, context.builder().command(), handshake.processStartTime(), specFile); - - ProcessListener listener = context.listener(); - if (listener != null) - listener.setProcess(process); - - Lang.thread(() -> { - try { - String line; - while ((line = reader.readLine()) != null) { - if (listener != null) - dispatchProtocolMessage(line, listener, process); - } - } catch (IOException e) { - LOG.error("Failed to read from the HMCL monitor", e); - } - }, "hmcl-monitor-reader", true); - - return process; - } - - /// Dispatches one protocol message received from the monitor. - private static void dispatchProtocolMessage(String line, ProcessListener listener, MonitorGameProcess process) { - MonitorProtocol.Message msg; - try { - msg = MonitorProtocol.Message.decode(line); - } catch (IllegalArgumentException e) { - LOG.warning("Unexpected message from HMCL monitor: " + line, e); - return; - } - - if (msg instanceof MonitorProtocol.Log log) { - listener.onLog(log.line(), log.isErrorStream()); - } else if (msg instanceof MonitorProtocol.Exit exit) { - if (exit.exitType() == ProcessListener.ExitType.JVM_ERROR) - EventBus.EVENT_BUS.fireEvent(new JVMLaunchFailedEvent(MonitorLauncher.class, process)); - else if (exit.exitType() != ProcessListener.ExitType.NORMAL) - EventBus.EVENT_BUS.fireEvent(new ProcessExitedAbnormallyEvent(MonitorLauncher.class, process)); - EventBus.EVENT_BUS.fireEvent(new ProcessStoppedEvent(MonitorLauncher.class, process)); - - listener.onExit(exit.exitCode(), exit.exitType()); - } - } - - /// @throws IOException if launcher cannot shake hand with monitor within [MonitorProtocol#HANDSHAKE_TIMEOUT] - private static MonitorProtocol.Pid readHandshake(Process monitor, BufferedReader reader) throws IOException { - CompletableFuture future = new CompletableFuture<>(); - Thread waiter = Lang.thread(() -> { - try { - String line = reader.readLine(); - if (line == null) - throw new IOException("The HMCL monitor exited before the handshake"); - if (MonitorProtocol.Message.decode(line) instanceof MonitorProtocol.Pid pid) { - future.complete(pid); - return; - } - throw new IOException("Unexpected handshake message from HMCL monitor: " + line); - } catch (Exception e) { - future.completeExceptionally(e); - } - }, "hmcl-monitor-handshake", true); - - boolean handshakeCompleted = false; - try { - MonitorProtocol.Pid handshake = future.get(MonitorProtocol.HANDSHAKE_TIMEOUT, TimeUnit.MILLISECONDS); - handshakeCompleted = true; - return handshake; - } catch (TimeoutException e) { - throw new IOException("Timed out waiting for the HMCL monitor handshake", e); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IOException("Interrupted while waiting for the HMCL monitor handshake", e); - } catch (ExecutionException e) { - Throwable cause = e.getCause() != null ? e.getCause() : e; - throw new IOException(cause.getMessage(), cause); - } finally { - waiter.interrupt(); - if (!handshakeCompleted) - monitor.destroy(); - } - } - - public enum RelaunchPolicy { - /// Never continue into the launcher UI after the game exits. - NEVER, - /// Continue into the launcher UI after a non-normal exit if the protocol stream has failed. - ON_CRASH, - /// Continue into the launcher UI after any exit if the protocol stream has failed. - ALWAYS - } - - public record MonitorLaunchContext(ProcessBuilder builder, @Nullable ProcessListener listener, Charset encoding, - LaunchOptions options, String gameDirectoryId, List postExitCommand, - RelaunchPolicy relaunchPolicy) { - } - - public record MonitorLaunchSpec(List command, String directory, Map environment, - boolean inheritStdin, String encoding, LaunchOptions options, - String gameDirectoryId, List postExitCommand, - RelaunchPolicy relaunchPolicy) { - public MonitorLaunchSpec(MonitorLaunchContext context) { - this(context.builder().command(), context.builder().directory().getAbsolutePath(), context.builder().environment(), - context.listener() == null, context.encoding().name(), - context.options(), context.gameDirectoryId(), context.postExitCommand(), - context.relaunchPolicy() - ); - } - } -} diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java index c691ebf051a..cbaa74e6ab4 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java @@ -18,13 +18,18 @@ package org.jackhuang.hmcl.monitor; import org.jackhuang.hmcl.launch.ProcessListener; +import org.jackhuang.hmcl.util.Lang; import org.jetbrains.annotations.NotNullByDefault; -import java.io.FileDescriptor; -import java.io.FileOutputStream; -import java.io.PrintStream; +import java.io.*; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.BiConsumer; +import java.util.function.Consumer; /// The line-based protocol spoken on the monitor process's stderr. /// @@ -40,47 +45,91 @@ final class MonitorProtocol { static final Charset CHARSET = StandardCharsets.UTF_8; /// How long to wait for the monitor's handshake before giving up, in milliseconds. static final long HANDSHAKE_TIMEOUT = 20_000; - /// The shared protocol output on raw stderr, also used to detect failures when writing messages. - static private final PrintStream protocol = new PrintStream(new FileOutputStream(FileDescriptor.err), true, MonitorProtocol.CHARSET); private MonitorProtocol() { } /// Writes and flushes the handshake message carrying the game process id and its start time. - static void pidMessage(long pid, long processStartTime) { + static void pidMessage(PrintStream protocol, long pid, long processStartTime) { protocol.println("P\t" + pid + "\t" + processStartTime); } /// Writes and flushes a game output line message. - static void logMessage(boolean isErrorStream, String line) { + static void logMessage(PrintStream protocol, boolean isErrorStream, String line) { protocol.println("L\t" + (isErrorStream ? 1 : 0) + "\t" + line); } /// Writes and flushes the game exit message carrying the exit code and its classified type. - static void exitMessage(int exitCode, ProcessListener.ExitType exitType) { + static void exitMessage(PrintStream protocol, int exitCode, ProcessListener.ExitType exitType) { protocol.println("E\t" + exitCode + "\t" + exitType.name()); } - static boolean parentGone() { - return protocol.checkError(); + static void readAll(BufferedReader reader, Consumer messageConsumer, BiConsumer err) throws IOException { + String line; + while ((line = reader.readLine()) != null) { + try { + messageConsumer.accept(decode(line)); + } catch (IllegalArgumentException e) { + err.accept(line, e); + } + } } - sealed interface Message permits Pid, Log, Exit { - static Message decode(String message) throws IllegalArgumentException { - String[] parts = message.split("\t", 3); + /// @throws IOException if launcher cannot shake hand with monitor within [MonitorProtocol#HANDSHAKE_TIMEOUT] + static Pid readHandshake(BufferedReader reader, Process monitor) throws IOException { + CompletableFuture future = new CompletableFuture<>(); + Thread waiter = Lang.thread(() -> { try { - return switch (parts[0]) { - case "P" -> new Pid(Long.parseLong(parts[1]), Long.parseLong(parts[2])); - case "L" -> new Log("1".equals(parts[1]), parts[2]); - case "E" -> new Exit(Integer.parseInt(parts[1]), ProcessListener.ExitType.valueOf(parts[2])); - default -> throw new IllegalStateException("Unexpected value: " + message.charAt(0)); - }; + String line = reader.readLine(); + if (line == null) + throw new IOException("The HMCL monitor exited before the handshake"); + if (decode(line) instanceof Pid pid) { + future.complete(pid); + return; + } + throw new IOException("Unexpected handshake message from HMCL monitor: " + line); } catch (Exception e) { - throw new IllegalArgumentException(e); + future.completeExceptionally(e); } + }, "hmcl-monitor-handshake", true); + + boolean handshakeCompleted = false; + try { + Pid handshake = future.get(HANDSHAKE_TIMEOUT, TimeUnit.MILLISECONDS); + handshakeCompleted = true; + return handshake; + } catch (TimeoutException e) { + throw new IOException("Timed out waiting for the HMCL monitor handshake", e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while waiting for the HMCL monitor handshake", e); + } catch (ExecutionException e) { + Throwable cause = e.getCause() != null ? e.getCause() : e; + throw new IOException(cause.getMessage(), cause); + } finally { + waiter.interrupt(); + if (!handshakeCompleted) + monitor.destroy(); } } + public static Message decode(String message) throws IllegalArgumentException { + String[] parts = message.split("\t", 3); + try { + return switch (parts[0]) { + case "P" -> new Pid(Long.parseLong(parts[1]), Long.parseLong(parts[2])); + case "L" -> new Log("1".equals(parts[1]), parts[2]); + case "E" -> new Exit(Integer.parseInt(parts[1]), ProcessListener.ExitType.valueOf(parts[2])); + default -> throw new IllegalStateException("Unexpected value: " + message.charAt(0)); + }; + } catch (Exception e) { + throw new IllegalArgumentException(e); + } + } + + sealed interface Message permits Pid, Log, Exit { + } + /// handshake message record Pid(long pid, long processStartTime) implements Message { } From 6cf505fcb4973c0607d34282ff1317987fc1476e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B1=BC=E5=89=8D=E4=B8=89=E7=99=BE?= Date: Thu, 22 Oct 2026 20:13:00 +0800 Subject: [PATCH 39/44] =?UTF-8?q?refactor:=20=E5=B0=86=20MonitorGameProces?= =?UTF-8?q?s=20=E9=87=8D=E6=9E=84=E4=B8=BA=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jackhuang/hmcl/game/LauncherHelper.java | 4 +- .../jackhuang/hmcl/ui/GameCrashWindow.java | 10 ++-- .../java/org/jackhuang/hmcl/ui/LogWindow.java | 4 +- .../hmcl/launch/DefaultLauncher.java | 3 +- .../hmcl/util/platform/ManagedProcess.java | 16 +++--- .../hmcl/util/platform/ProcessInfo.java | 10 ++-- .../org/jackhuang/hmcl/monitor/Monitor.java | 6 +-- .../hmcl/monitor/MonitorGameProcess.java | 54 +++++-------------- 8 files changed, 39 insertions(+), 68 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java index 279e15fcf31..fa37d541393 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -859,11 +859,11 @@ public HMCLProcessListener(AuthInfo authInfo, LaunchOptions launchOptions, Count public void setProcess(ProcessInfo process) { this.process = process; - String command = new CommandBuilder().addAll(process.getCommands()).toString(); + String command = new CommandBuilder().addAll(process.commands()).toString(); LOG.info("Launched process: " + command); - String classpath = process.getClasspath(); + String classpath = process.classpath(); if (classpath != null) { LOG.info("Process ClassPath: " + classpath); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java index d26fae33de1..ab36dfaf61a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java @@ -262,9 +262,9 @@ private String parseFabricModId(String modName) { private void showLogWindow() { LogWindow logWindow = new LogWindow(managedProcess); - logWindow.logLine(new Log(Logger.filterForbiddenToken("Command: " + new CommandBuilder().addAll(managedProcess.getCommands())), Log4jLevel.INFO)); - if (managedProcess.getClasspath() != null) - logWindow.logLine(new Log("ClassPath: " + managedProcess.getClasspath(), Log4jLevel.INFO)); + logWindow.logLine(new Log(Logger.filterForbiddenToken("Command: " + new CommandBuilder().addAll(managedProcess.commands())), Log4jLevel.INFO)); + if (managedProcess.classpath() != null) + logWindow.logLine(new Log("ClassPath: " + managedProcess.classpath(), Log4jLevel.INFO)); logWindow.logLines(logs); logWindow.show(); } @@ -275,10 +275,10 @@ private CompletableFuture exportGameCrashInfo() { return CompletableFuture.supplyAsync(() -> logs.stream().map(Log::getLog).collect(Collectors.joining("\n"))) .thenComposeAsync(logs -> { - long processStartTime = managedProcess.getProcessStartTime(); + long processStartTime = managedProcess.processStartTime(); return LogExporter.exportLogs(logFile, gameInstance, launchOptions, logs, - new CommandBuilder().addAll(managedProcess.getCommands()).toString(), + new CommandBuilder().addAll(managedProcess.commands()).toString(), path -> { try { FileTime lastModifiedTime = Files.getLastModifiedTime(path); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/LogWindow.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/LogWindow.java index 86b2f877ec3..f6ceb73eef0 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/LogWindow.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/LogWindow.java @@ -235,7 +235,7 @@ private void onExportDump(SpinnerPane pane) { try { if (gameProcess.isRunning()) { - GameDumpGenerator.writeDumpTo(gameProcess.getPid(), dumpFile); + GameDumpGenerator.writeDumpTo(gameProcess.pid(), dumpFile); FXUtils.showFileInExplorer(dumpFile); } } catch (Throwable e) { @@ -433,7 +433,7 @@ protected void updateItem(Log item, boolean empty) { clearButton.setOnAction(e -> getSkinnable().onClear()); hBox.getChildren().setAll(autoScrollCheckBox, wrapTextCheckBox, exportLogsButton, terminateButton, exportDumpPane, clearButton); - var handle = control.getGameProcess().getHandle(); + var handle = control.getGameProcess().handle(); if (handle != null) { handle.onExit().thenRunAsync(() -> { terminateButton.setDisable(true); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java index 8151f1d37f6..510ea2e770c 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java @@ -710,8 +710,7 @@ protected ProcessInfo startProcess(List commandLine, Map if (appdata != null) builder.environment().put("APPDATA", appdata.toString()); builder.environment().putAll(envVars); - Process process = builder.start(); - ManagedProcess p = new ManagedProcess(process, builder.command()); + ManagedProcess p = new ManagedProcess(builder); if (listener != null) startMonitors(p, getNativeFolder(), listener, encoding, daemon); return p; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ManagedProcess.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ManagedProcess.java index 646fb8071d6..b03dd69a020 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ManagedProcess.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ManagedProcess.java @@ -19,6 +19,7 @@ import org.jackhuang.hmcl.launch.StreamPump; import org.jackhuang.hmcl.util.Lang; +import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; import java.io.IOException; @@ -33,11 +34,12 @@ /// @author huangyuhui /// /// @see org.jackhuang.hmcl.launch.StreamPump +@NotNullByDefault public final class ManagedProcess implements ProcessInfo { private final ReentrantLock lock = new ReentrantLock(); private final Process process; private final List commands; - private final String classpath; + private final @Nullable String classpath; private final long processStartTime; private final Map properties = new HashMap<>(); private final List lines = new ArrayList<>(); @@ -87,17 +89,17 @@ public Process getProcess() { } @Override - public long getPid() { + public long pid() { return process.pid(); } @Override - public ProcessHandle getHandle() { + public ProcessHandle handle() { return process.toHandle(); } @Override - public long getProcessStartTime() { + public long processStartTime() { return processStartTime; } @@ -107,7 +109,7 @@ public long getProcessStartTime() { * @return the list of each part of command line separated by spaces. */ @Override - public List getCommands() { + public List commands() { return commands; } @@ -117,7 +119,7 @@ public List getCommands() { * @return classpath */ @Override - public @Nullable String getClasspath() { + public @Nullable String classpath() { return classpath; } @@ -134,7 +136,7 @@ public Map getProperties() { * * @see #addLine */ - public List getLines(Predicate lineFilter) { + public List getLines(@Nullable Predicate lineFilter) { lock.lock(); try { if (lineFilter == null) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ProcessInfo.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ProcessInfo.java index 75c623d0b1b..e79f05021e2 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ProcessInfo.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/platform/ProcessInfo.java @@ -29,20 +29,20 @@ public interface ProcessInfo { /// The process id of the process. - long getPid(); + long pid(); /// The handle of the process, or `null` if it cannot be resolved anymore, e.g. a dead /// process whose id has already been reaped by the operating system. - @Nullable ProcessHandle getHandle(); + @Nullable ProcessHandle handle(); /// The command line of the process. - List getCommands(); + List commands(); /// The classpath of the process, or `null` when unknown. - @Nullable String getClasspath(); + @Nullable String classpath(); /// The epoch millisecond at which the process started. - long getProcessStartTime(); + long processStartTime(); /// Returns whether the process is still running. boolean isRunning(); diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java index 31a1be88b8a..aefc2f0c01c 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java @@ -87,13 +87,13 @@ public static void start(Path specPath) throws IOException, InterruptedException pb.environment().putAll(spec.environment); if (spec.inheritStdin) pb.redirectInput(ProcessBuilder.Redirect.INHERIT); - ManagedProcess gameProcess = new ManagedProcess(pb.start(), spec.command); + ManagedProcess gameProcess = new ManagedProcess(pb); - LOG.info("Game process created, pid " + gameProcess.getPid() + ", spec " + specPath); + LOG.info("Game process created, pid " + gameProcess.pid() + ", spec " + specPath); // shake hand PrintStream protocol = new PrintStream(new FileOutputStream(FileDescriptor.err), true, MonitorProtocol.CHARSET); - MonitorProtocol.pidMessage(protocol, gameProcess.getPid(), gameProcess.getProcessStartTime()); + MonitorProtocol.pidMessage(protocol, gameProcess.pid(), gameProcess.processStartTime()); // pump game io BiConsumer sink = (line, isErrorStream) -> { diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java index fe319119872..7437a6f4d28 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java @@ -17,6 +17,7 @@ */ package org.jackhuang.hmcl.monitor; +import org.jackhuang.hmcl.util.platform.ManagedProcess; import org.jackhuang.hmcl.util.platform.ProcessInfo; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; @@ -30,59 +31,28 @@ /// A view of the game process supervised by the HMCL monitor. /// -/// Unlike [org.jackhuang.hmcl.util.platform.ManagedProcess], the game process is not spawned nor +/// Unlike [ManagedProcess], the game process is not spawned nor /// owned by this JVM. Its handle is resolved from the monitor's handshake and may be unavailable /// if the game has already exited. The original command line is supplied by the launch context. +/// +/// @param handle the handle of the game process, or `null` if it cannot be resolved anymore +/// @param commands the command line of the game process +/// @param processStartTime the epoch millisecond at which the game process started +/// @param specFile The file the spec was exchanged with the monitor through @NotNullByDefault -public final class MonitorGameProcess implements ProcessInfo { - - private final @Nullable ProcessHandle handle; - private final long pid; - private final List commands; - private final long processStartTime; - /// The file the spec was exchanged with the monitor through - private final Path specFile; +public record MonitorGameProcess(@Nullable ProcessHandle handle, long pid, List commands, long processStartTime, + Path specFile) implements ProcessInfo { /// Creates a view of the monitor-supervised game process. - /// - /// @param handle the handle of the game process, or `null` if it cannot be resolved anymore - /// @param commands the command line of the game process - /// @param processStartTime the epoch millisecond at which the game process started - /// @param specFile the file the spec was exchanged with the monitor through, or `null` if unknown - MonitorGameProcess(@Nullable ProcessHandle handle, List commands, long processStartTime, - Path specFile) { - this.handle = handle; - this.pid = handle != null ? handle.pid() : -1; - this.commands = List.copyOf(commands); - this.processStartTime = processStartTime; - this.specFile = specFile; - } - - @Override - public long getPid() { - return pid; - } - - @Override - public @Nullable ProcessHandle getHandle() { - return handle; - } - - @Override - public List getCommands() { - return commands; + public MonitorGameProcess(@Nullable ProcessHandle handle, List commands, long processStartTime, Path specFile) { + this(handle, handle != null ? handle.pid() : -1, List.copyOf(commands), processStartTime, specFile); } @Override - public @Nullable String getClasspath() { + public @Nullable String classpath() { return null; } - @Override - public long getProcessStartTime() { - return processStartTime; - } - @Override public boolean isRunning() { ProcessHandle handle = this.handle; From 9b4427c4924118f553477d9e959b5f290ab004af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B1=BC=E5=89=8D=E4=B8=89=E7=99=BE?= Date: Thu, 22 Oct 2026 20:19:54 +0800 Subject: [PATCH 40/44] =?UTF-8?q?chore:=20=E7=A7=BB=E9=99=A4=E4=B8=8D?= =?UTF-8?q?=E9=9C=80=E8=A6=81=E7=9A=84=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java index 7437a6f4d28..070d5f5efd0 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java @@ -17,7 +17,6 @@ */ package org.jackhuang.hmcl.monitor; -import org.jackhuang.hmcl.util.platform.ManagedProcess; import org.jackhuang.hmcl.util.platform.ProcessInfo; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; @@ -31,7 +30,7 @@ /// A view of the game process supervised by the HMCL monitor. /// -/// Unlike [ManagedProcess], the game process is not spawned nor +/// Unlike [org.jackhuang.hmcl.util.platform.ManagedProcess], the game process is not spawned nor /// owned by this JVM. Its handle is resolved from the monitor's handshake and may be unavailable /// if the game has already exited. The original command line is supplied by the launch context. /// From 873bf1c65ea71fa4057dcd710a2566d056bd5800 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B1=BC=E5=89=8D=E4=B8=89=E7=99=BE?= Date: Fri, 23 Oct 2026 09:29:04 +0800 Subject: [PATCH 41/44] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20token=20?= =?UTF-8?q?=E6=B3=84=E9=9C=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/monitor/MonitorCrashReporter.java | 4 +++- .../hmcl/monitor/MonitorLauncher.java | 13 ++++++++--- .../org/jackhuang/hmcl/monitor/Monitor.java | 4 ++-- .../hmcl/monitor/MonitorGameProcess.java | 11 +++------ .../hmcl/monitor/MonitorProtocol.java | 23 +++++++++++-------- 5 files changed, 32 insertions(+), 23 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java index b0ed1eee948..e9f3b0a0eea 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorCrashReporter.java @@ -28,6 +28,7 @@ import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.ui.GameCrashWindow; import org.jackhuang.hmcl.util.Log4jLevel; +import org.jackhuang.hmcl.util.logging.Logger; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Unmodifiable; @@ -77,6 +78,7 @@ public static void show() { /// Schedules the crash window on the JavaFX thread using the configured tail of captured console output. private static void presentCrashWindow(Monitor.Result result, HMCLGameInstance instance) { + instance.markLaunchedAbnormally(); @Unmodifiable List logs = result.process().getLines(null); Platform.runLater(() -> new GameCrashWindow( result.process(), @@ -84,7 +86,7 @@ private static void presentCrashWindow(Monitor.Result result, HMCLGameInstance i instance, result.launchOptions(), logs.subList(Math.max(0, logs.size() - Log.getLogLines()), logs.size()).stream() - .map(line -> new Log(line, Log4jLevel.guessLevel(line))) + .map(line -> new Log(Logger.filterForbiddenToken(line), Log4jLevel.guessLevel(line))) .toList()).show()); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java index bcaafd2b3f6..dd27595c608 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java @@ -125,7 +125,7 @@ private static Path writeSpecFile(List commandLine, } private static MonitorGameProcess startProcess(List commandLine, @Nullable ProcessListener listener, Path specFile) throws IOException { - Path thisJar = JarUtils.thisJarPath(); + @Nullable Path thisJar = JarUtils.thisJarPath(); if (thisJar == null) throw new IOException("Failed to find current HMCL location"); @@ -135,7 +135,14 @@ private static MonitorGameProcess startProcess(List commandLine, @Nullab BufferedReader reader = new BufferedReader(new InputStreamReader(monitor.getErrorStream(), MonitorProtocol.CHARSET)); - MonitorProtocol.Pid handshake = MonitorProtocol.readHandshake(reader, monitor); + MonitorProtocol.Pid handshake = MonitorProtocol.readHandshake(reader, () -> { + monitor.destroy(); + try { + Files.deleteIfExists(specFile); + } catch (IOException e) { + LOG.warning("Failed to clean specFile" + e); + } + }); ProcessHandle gameHandle = ProcessHandle.of(handshake.pid()).orElse(null); MonitorGameProcess process = new MonitorGameProcess(gameHandle, commandLine, handshake.processStartTime(), specFile); @@ -161,7 +168,7 @@ else if (exit.exitType() != ProcessListener.ExitType.NORMAL) } }, (line, e) -> LOG.warning("Unexpected message from HMCL monitor: " + line, e)); } catch (IOException e) { - LOG.error("Failed to read from the HMCL monitor", e); + LOG.error("IOException occurred during communicating with HMCL monitor.", e); } }, "hmcl-monitor-reader", true); return process; diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java index aefc2f0c01c..4cedc193e01 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java @@ -42,7 +42,7 @@ @NotNullByDefault public final class Monitor { - public static final String CANCELED = "CANCELED"; + public static final String canceled = "CANCELED"; private static boolean shouldRelaunch = false; private static boolean crashed = false; private static @Nullable Monitor.Result result; @@ -116,7 +116,7 @@ public static void start(Path specPath) throws IOException, InterruptedException // reread spec to check canceled ProcessListener.ExitType reportedType = exitType; try { - if (Files.exists(specPath) && Files.readString(specPath).startsWith(CANCELED)) + if (Files.exists(specPath) && Files.readString(specPath).startsWith(canceled)) reportedType = ProcessListener.ExitType.INTERRUPTED; Files.deleteIfExists(specPath); } catch (IOException e1) { diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java index 070d5f5efd0..919e1d9656e 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java @@ -40,16 +40,11 @@ /// @param specFile The file the spec was exchanged with the monitor through @NotNullByDefault public record MonitorGameProcess(@Nullable ProcessHandle handle, long pid, List commands, long processStartTime, - Path specFile) implements ProcessInfo { + Path specFile, @Nullable String classpath) implements ProcessInfo { /// Creates a view of the monitor-supervised game process. public MonitorGameProcess(@Nullable ProcessHandle handle, List commands, long processStartTime, Path specFile) { - this(handle, handle != null ? handle.pid() : -1, List.copyOf(commands), processStartTime, specFile); - } - - @Override - public @Nullable String classpath() { - return null; + this(handle, handle != null ? handle.pid() : -1, List.copyOf(commands), processStartTime, specFile, null); } @Override @@ -61,7 +56,7 @@ public boolean isRunning() { @Override public void stop() { try { // notify the monitor - Files.writeString(specFile, Monitor.CANCELED); + Files.writeString(specFile, Monitor.canceled); } catch (IOException e) { LOG.warning("Failed to rewrite the monitor spec file " + specFile + " to mark the cancellation", e); } diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java index cbaa74e6ab4..9443f3c76f4 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java @@ -76,18 +76,23 @@ static void readAll(BufferedReader reader, Consumer messageConsumer, Bi } /// @throws IOException if launcher cannot shake hand with monitor within [MonitorProtocol#HANDSHAKE_TIMEOUT] - static Pid readHandshake(BufferedReader reader, Process monitor) throws IOException { + static Pid readHandshake(BufferedReader reader, Runnable failClean) throws IOException { CompletableFuture future = new CompletableFuture<>(); Thread waiter = Lang.thread(() -> { try { - String line = reader.readLine(); - if (line == null) - throw new IOException("The HMCL monitor exited before the handshake"); - if (decode(line) instanceof Pid pid) { - future.complete(pid); - return; + String line; + while ((line = reader.readLine()) != null) { + try { + if (decode(line) instanceof Pid pid) { + future.complete(pid); + return; + } + throw new IOException("Unexpected handshake message from HMCL monitor: " + line); + } catch (IllegalArgumentException e) { + // ignore regular err messages + } } - throw new IOException("Unexpected handshake message from HMCL monitor: " + line); + throw new IOException("The HMCL monitor exited before the handshake"); } catch (Exception e) { future.completeExceptionally(e); } @@ -109,7 +114,7 @@ static Pid readHandshake(BufferedReader reader, Process monitor) throws IOExcept } finally { waiter.interrupt(); if (!handshakeCompleted) - monitor.destroy(); + failClean.run(); } } From 4fcf6d8cf3df988fcc42c8277fbd43f8ef8611ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B1=BC=E5=89=8D=E4=B8=89=E7=99=BE?= Date: Fri, 23 Oct 2026 14:40:19 +0800 Subject: [PATCH 42/44] =?UTF-8?q?refactor:=20=E4=BD=BF=E7=94=A8=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E9=80=9A=E9=81=93=E7=AE=A1=E7=90=86=20Monitor=20?= =?UTF-8?q?=E7=9A=84=E5=8F=96=E6=B6=88=E4=B8=8E=E9=80=80=E5=87=BA=E5=A4=84?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jackhuang/hmcl/game/LauncherHelper.java | 8 +- .../hmcl/monitor/MonitorLauncher.java | 43 +++-- .../org/jackhuang/hmcl/monitor/Monitor.java | 60 ++++--- .../hmcl/monitor/MonitorGameProcess.java | 168 +++++++++++++++--- .../hmcl/monitor/MonitorProtocol.java | 126 +++++++------ 5 files changed, 282 insertions(+), 123 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java index fa37d541393..5a324e52e8b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -23,7 +23,7 @@ import org.jackhuang.hmcl.auth.*; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDownloadException; import org.jackhuang.hmcl.auth.offline.OfflineAccount; -import org.jackhuang.hmcl.monitor.MonitorLauncher; +import org.jackhuang.hmcl.monitor.MonitorGameProcess; import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.DownloadProvider; import org.jackhuang.hmcl.download.game.*; @@ -960,6 +960,7 @@ private void finishLaunch() { } } + /// Records game output and transfers exit handling before scheduling monitor-mode shutdown. @Override public void onLog(String log, boolean isErrorStream) { if (isErrorStream) @@ -971,7 +972,7 @@ public void onLog(String log, boolean isErrorStream) { if (forbiddenAccessToken != null) log = log.replace(forbiddenAccessToken, ""); - Log4jLevel level = isErrorStream && !log.startsWith("[authlib-injector]") ? Log4jLevel.ERROR : null; + @Nullable Log4jLevel level = isErrorStream && !log.startsWith("[authlib-injector]") ? Log4jLevel.ERROR : null; if (showLogs) { if (level == null) level = Objects.requireNonNullElse(Log4jLevel.guessLevel(log), Log4jLevel.INFO); @@ -994,7 +995,8 @@ public void onLog(String log, boolean isErrorStream) { try { if (!lwjgl) { lwjgl = true; - if (MonitorLauncher.MONITOR_ENABLED && !showLogs && launcherVisibility != LauncherVisibility.KEEP) { + if (process instanceof MonitorGameProcess monitorProcess && !showLogs && launcherVisibility != LauncherVisibility.KEEP) { + monitorProcess.detach(); launchingLatch.countDown(); runLater(Launcher::stopApplication); } else { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java index dd27595c608..92efbde51c1 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java @@ -76,21 +76,21 @@ public static MonitorGameProcess launchNewMonitor(List commandLine, @Nullable ProcessListener listener, HMCLGameInstance hmclGameInstance, LaunchOptions options) throws IOException { - Path specFile = writeSpecFile(commandLine, envVars, encoding, listener, hmclGameInstance, options); + Path specFile = writeSpecFile(commandLine, envVars, encoding, hmclGameInstance, options); return startProcess(commandLine, listener, specFile); } + /// Writes the one-time launch specification; subsequent commands use the control pipe. private static Path writeSpecFile(List commandLine, Map envVars, Charset encoding, - @Nullable ProcessListener listener, HMCLGameInstance hmclGameInstance, LaunchOptions options) throws IOException { Path runDirectory = hmclGameInstance.getRunDirectory().toAbsolutePath(); String gameDirectoryId = hmclGameInstance.getRepository().getGameDirectory().getId().toString(); Map gameEnv = new HashMap<>(); - Path appdata = options.getGameDir().toAbsolutePath().getParent(); + @Nullable Path appdata = options.getGameDir().toAbsolutePath().getParent(); if (appdata != null) gameEnv.put("APPDATA", appdata.toString()); gameEnv.putAll(envVars); @@ -113,7 +113,6 @@ private static Path writeSpecFile(List commandLine, commandLine, runDirectory, gameEnv, - listener == null, encoding.name(), options, gameDirectoryId, @@ -124,18 +123,20 @@ private static Path writeSpecFile(List commandLine, return specFile; } + /// Starts the monitor and acknowledges exit handling only after the listener completes. private static MonitorGameProcess startProcess(List commandLine, @Nullable ProcessListener listener, Path specFile) throws IOException { @Nullable Path thisJar = JarUtils.thisJarPath(); if (thisJar == null) throw new IOException("Failed to find current HMCL location"); Process monitor = JavaProcessLauncher.buildJava(thisJar, "--monitor", specFile.toAbsolutePath().toString()) + .redirectInput(ProcessBuilder.Redirect.PIPE) .redirectOutput(ProcessBuilder.Redirect.INHERIT) .redirectError(ProcessBuilder.Redirect.PIPE).start(); BufferedReader reader = new BufferedReader(new InputStreamReader(monitor.getErrorStream(), MonitorProtocol.CHARSET)); - MonitorProtocol.Pid handshake = MonitorProtocol.readHandshake(reader, () -> { + MonitorProtocol.MonitorMessage.Pid handshake = MonitorProtocol.readHandshake(reader, () -> { monitor.destroy(); try { Files.deleteIfExists(specFile); @@ -143,30 +144,34 @@ private static MonitorGameProcess startProcess(List commandLine, @Nullab LOG.warning("Failed to clean specFile" + e); } }); - ProcessHandle gameHandle = ProcessHandle.of(handshake.pid()).orElse(null); - MonitorGameProcess process = new MonitorGameProcess(gameHandle, commandLine, handshake.processStartTime(), specFile); + @Nullable ProcessHandle gameHandle = ProcessHandle.of(handshake.pid()).orElse(null); + MonitorGameProcess process = new MonitorGameProcess(gameHandle, commandLine, handshake.processStartTime(), monitor); if (listener != null) listener.setProcess(process); + else + process.detach(); Lang.thread(() -> { - try { - MonitorProtocol.readAll(reader, msg -> { + try (reader) { + MonitorProtocol.readMonitorMessage(reader, msg -> { if (listener == null) return; - if (msg instanceof MonitorProtocol.Log log) { + if (msg instanceof MonitorProtocol.MonitorMessage.Log log) { listener.onLog(log.line(), log.isErrorStream()); - } else if (msg instanceof MonitorProtocol.Exit exit) { - if (exit.exitType() == ProcessListener.ExitType.JVM_ERROR) - EventBus.EVENT_BUS.fireEvent(new JVMLaunchFailedEvent(MonitorLauncher.class, process)); - else if (exit.exitType() != ProcessListener.ExitType.NORMAL) - EventBus.EVENT_BUS.fireEvent(new ProcessExitedAbnormallyEvent(MonitorLauncher.class, process)); - EventBus.EVENT_BUS.fireEvent(new ProcessStoppedEvent(MonitorLauncher.class, process)); - - listener.onExit(exit.exitCode(), exit.exitType()); + } else if (msg instanceof MonitorProtocol.MonitorMessage.Exit exit) { + process.handleExit(() -> { // if we are still alive and can handle exit + if (exit.exitType() == ProcessListener.ExitType.JVM_ERROR) + EventBus.EVENT_BUS.fireEvent(new JVMLaunchFailedEvent(MonitorLauncher.class, process)); + else if (exit.exitType() != ProcessListener.ExitType.NORMAL) + EventBus.EVENT_BUS.fireEvent(new ProcessExitedAbnormallyEvent(MonitorLauncher.class, process)); + EventBus.EVENT_BUS.fireEvent(new ProcessStoppedEvent(MonitorLauncher.class, process)); + + listener.onExit(exit.exitCode(), exit.exitType()); + }); } - }, (line, e) -> LOG.warning("Unexpected message from HMCL monitor: " + line, e)); + }); } catch (IOException e) { LOG.error("IOException occurred during communicating with HMCL monitor.", e); } diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java index 4cedc193e01..5e67d772dbf 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/Monitor.java @@ -36,13 +36,14 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.BiConsumer; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @NotNullByDefault public final class Monitor { - public static final String canceled = "CANCELED"; private static boolean shouldRelaunch = false; private static boolean crashed = false; private static @Nullable Monitor.Result result; @@ -70,6 +71,7 @@ public static boolean processArguments(String[] args) { return !shouldRelaunch; } + /// Supervises the game and waits for the launcher's exit decision before restoring its UI. public static void start(Path specPath) throws IOException, InterruptedException { LOG.info("*** HMCL MONITOR ***"); @@ -85,20 +87,41 @@ public static void start(Path specPath) throws IOException, InterruptedException // start game process ProcessBuilder pb = new ProcessBuilder(spec.command).directory(spec.directory.toFile()); pb.environment().putAll(spec.environment); - if (spec.inheritStdin) - pb.redirectInput(ProcessBuilder.Redirect.INHERIT); ManagedProcess gameProcess = new ManagedProcess(pb); + LOG.info("Game process created, pid " + gameProcess.pid()); + + // Read commands while the game is running. The monitor must record CANCEL + // before destroying the game so its exit cannot race ahead of cancellation. + AtomicBoolean canceled = new AtomicBoolean(); + CompletableFuture launcherDetached = new CompletableFuture<>(); + Lang.thread(() -> { + try { + MonitorProtocol.readLauncherControl( + new BufferedReader(new InputStreamReader(System.in, MonitorProtocol.CHARSET)), (command) -> { + switch (command) { + case CANCEL -> { + canceled.set(true); + gameProcess.getProcess().destroy(); + } + case DETACH -> launcherDetached.complete(true); + case HANDLED -> launcherDetached.complete(false); + } + }); + } catch (IOException e) { + LOG.warning("Lost launcher control channel; taking over exit handling", e); + } + launcherDetached.complete(true); + }, "monitor-control-reader", true); - LOG.info("Game process created, pid " + gameProcess.pid() + ", spec " + specPath); // shake hand PrintStream protocol = new PrintStream(new FileOutputStream(FileDescriptor.err), true, MonitorProtocol.CHARSET); - MonitorProtocol.pidMessage(protocol, gameProcess.pid(), gameProcess.processStartTime()); + protocol.println(new MonitorProtocol.MonitorMessage.Pid(gameProcess.pid(), gameProcess.processStartTime()).encode()); // pump game io BiConsumer sink = (line, isErrorStream) -> { gameProcess.addLine(line); - MonitorProtocol.logMessage(protocol, isErrorStream, line); + protocol.println(new MonitorProtocol.MonitorMessage.Log(isErrorStream, line).encode()); }; Charset encoding = Charset.forName(spec.encoding); @@ -113,19 +136,13 @@ public static void start(Path specPath) throws IOException, InterruptedException Lang.thread(new ExitWaiter(gameProcess, List.of(stdoutPump, stderrPump), (exitCode, exitType) -> { LOG.info("Game exited with code " + exitCode + "(" + Integer.toHexString(exitCode) + "), type " + exitType); - // reread spec to check canceled - ProcessListener.ExitType reportedType = exitType; - try { - if (Files.exists(specPath) && Files.readString(specPath).startsWith(canceled)) - reportedType = ProcessListener.ExitType.INTERRUPTED; - Files.deleteIfExists(specPath); - } catch (IOException e1) { - LOG.warning("Failed to check whether to cancel " + specPath, e1); - } - MonitorProtocol.exitMessage(protocol, exitCode, reportedType); + ProcessListener.ExitType reportedType = canceled.get() ? ProcessListener.ExitType.INTERRUPTED : exitType; + protocol.println(new MonitorProtocol.MonitorMessage.Exit(exitCode, reportedType).encode()); - crashed = reportedType != ProcessListener.ExitType.NORMAL; - shouldRelaunch = protocol.checkError() && switch (spec.relaunchPolicy) { + crashed = reportedType != ProcessListener.ExitType.NORMAL && reportedType != ProcessListener.ExitType.INTERRUPTED; + // E may overtake the launcher's handling of the last LWJGL log. Wait for + // an explicit decision instead of sampling whether its stderr pipe broke. + shouldRelaunch = launcherDetached.join() && switch (spec.relaunchPolicy) { case ALWAYS -> true; case ON_CRASH -> crashed; case NEVER -> false; @@ -147,9 +164,9 @@ public static void start(Path specPath) throws IOException, InterruptedException public enum RelaunchPolicy { /// Never continue into the launcher UI after the game exits. NEVER, - /// Continue into the launcher UI after a non-normal exit if the protocol stream has failed. + /// Continue into the launcher UI after a non-normal exit if the launcher relinquishes exit handling. ON_CRASH, - /// Continue into the launcher UI after any exit if the protocol stream has failed. + /// Continue into the launcher UI after any exit if the launcher relinquishes exit handling. ALWAYS } @@ -163,8 +180,9 @@ public enum RelaunchPolicy { public record Result(ManagedProcess process, ProcessListener.ExitType exitType, String gameDirectoryId, LaunchOptions launchOptions) { } + /// One-time game configuration, consumed and deleted before the game starts. public record MonitorLaunchSpec(List command, Path directory, Map environment, - boolean inheritStdin, String encoding, LaunchOptions options, + String encoding, LaunchOptions options, String gameDirectoryId, List postExitCommand, RelaunchPolicy relaunchPolicy) { } diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java index 919e1d9656e..d5397ac4f4b 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java @@ -17,50 +17,172 @@ */ package org.jackhuang.hmcl.monitor; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.util.platform.ManagedProcess; import org.jackhuang.hmcl.util.platform.ProcessInfo; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; +import java.io.PrintStream; import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import static org.jackhuang.hmcl.util.logging.Logger.LOG; -/// A view of the game process supervised by the HMCL monitor. +/// A view of the game process launched by the HMCL monitor. /// -/// Unlike [org.jackhuang.hmcl.util.platform.ManagedProcess], the game process is not spawned nor -/// owned by this JVM. Its handle is resolved from the monitor's handshake and may be unavailable -/// if the game has already exited. The original command line is supplied by the launch context. -/// -/// @param handle the handle of the game process, or `null` if it cannot be resolved anymore -/// @param commands the command line of the game process -/// @param processStartTime the epoch millisecond at which the game process started -/// @param specFile The file the spec was exchanged with the monitor through +/// Unlike [ManagedProcess], this process may not be resolved when it exited before +/// monitor's handshake. @NotNullByDefault -public record MonitorGameProcess(@Nullable ProcessHandle handle, long pid, List commands, long processStartTime, - Path specFile, @Nullable String classpath) implements ProcessInfo { +public final class MonitorGameProcess implements ProcessInfo { + /// the handle of the game process, or `null` if it cannot be resolved. + private final @Nullable ProcessHandle handle; + /// the command line of the game process + private final @Unmodifiable List commands; + /// the epoch millisecond at which the game process started + private final long processStartTime; + /// the monitor process + private final Process monitor; + /// the channel to send command to monitor. e.g. transfer responsibility for handling game exit / cancel launching + private final MonitorController controller; /// Creates a view of the monitor-supervised game process. - public MonitorGameProcess(@Nullable ProcessHandle handle, List commands, long processStartTime, Path specFile) { - this(handle, handle != null ? handle.pid() : -1, List.copyOf(commands), processStartTime, specFile, null); + public MonitorGameProcess(@Nullable ProcessHandle handle, List commands, long processStartTime, Process monitor) { + this.handle = handle; + this.commands = List.copyOf(commands); + this.processStartTime = processStartTime; + this.monitor = monitor; + this.controller = new MonitorController(monitor); + } + + /// Transfers exit handling to the monitor before the launcher begins shutting down. + public void detach() { + controller.detach(); + } + + /// Handles exit when monitor is attached and acknowledges that the exit callback has completed. + public void handleExit(Runnable handler) { + if (controller.isDetached()) + return; + handler.run(); + controller.handled(); } @Override public boolean isRunning() { - ProcessHandle handle = this.handle; return handle != null && handle.isAlive(); } + /// Requests cancellation over the control channel; the monitor owns game termination. @Override public void stop() { - try { // notify the monitor - Files.writeString(specFile, Monitor.canceled); - } catch (IOException e) { - LOG.warning("Failed to rewrite the monitor spec file " + specFile + " to mark the cancellation", e); + if (handle == null) + return; + Task.runAsync(() -> { + if (!controller.cancel().await(MonitorProtocol.TIMEOUT, TimeUnit.MILLISECONDS)) + handle.destroy(); + }).whenComplete(exception -> { + if (exception != null) + handle.destroy(); + }).start(); + } + + @Override + public @Nullable ProcessHandle handle() { + return handle; + } + + @Override + public long pid() { + return handle != null ? handle.pid() : -1; + } + + @Override + public @Unmodifiable List commands() { + return commands; + } + + @Override + public long processStartTime() { + return processStartTime; + } + + @Override + public @Nullable String classpath() { + return null; + } + + public Process getMonitor() { + return monitor; + } + + /// Serializes cancellation and exit decisions onto monitor stdin. + @NotNullByDefault + private static final class MonitorController { + /// UTF-8 control writer, kept open after DETACH so cancellation remains available. + private final PrintStream writer; + private final CountDownLatch exited = new CountDownLatch(1); + /// Current owner of exit handling; all transitions are protected by this object's monitor. + private State state = State.ATTACHED; + + /// Creates a control channel backed by the monitor process's stdin. + MonitorController(Process monitor) { + writer = new PrintStream(monitor.getOutputStream(), true, MonitorProtocol.CHARSET); + } + + /// Sends DETACH exactly once, before launcher shutdown is scheduled. + synchronized void detach() { + if (state != State.ATTACHED) + return; + state = State.DETACHED; + send(MonitorProtocol.ControlCommand.DETACH); + } + + /// Returns whether the monitor owns exit handling. + synchronized boolean isDetached() { + return state == State.DETACHED; + } + + /// Sends HANDLED only if the launcher still owns exit handling. + synchronized void handled() { + if (state != State.ATTACHED) + return; + state = State.HANDLED; + exited.countDown(); + send(MonitorProtocol.ControlCommand.HANDLED); + writer.close(); + } + + /// Asks the monitor to record cancellation before terminating the game. + /// + /// @return [CountDownLatch] when SUCCESSFULLY canceled + synchronized CountDownLatch cancel() { + return switch (state) { + case ATTACHED, DETACHED -> { + send(MonitorProtocol.ControlCommand.CANCEL); + yield exited; + } + case HANDLED -> new CountDownLatch(0); // The game exit has already been handled. + }; + } + + /// Writes and flushes one command while the caller holds the state lock. + private void send(MonitorProtocol.ControlCommand command) { + writer.println(command.name()); + if (writer.checkError()) + LOG.warning("Failed to send monitor control command: " + command); + } + + /// Exit ownership is independent of cancellation, which is allowed until HANDLED. + private enum State { + /// The launcher owns exit handling and may cancel or transfer ownership. + ATTACHED, + /// The monitor owns exit handling; the launcher may still cancel the game. + DETACHED, + /// The launcher completed exit handling and closed the control pipe. + HANDLED } - if (handle != null) - handle.destroy(); } } diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java index 9443f3c76f4..9b6a7d5ea26 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorProtocol.java @@ -20,6 +20,7 @@ import org.jackhuang.hmcl.launch.ProcessListener; import org.jackhuang.hmcl.util.Lang; import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; import java.io.*; import java.nio.charset.Charset; @@ -28,71 +29,58 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; -import java.util.function.BiConsumer; import java.util.function.Consumer; -/// The line-based protocol spoken on the monitor process's stderr. -/// -///

    Messages are UTF-8 encoded, one per line, with tab-separated fields: -///

      -///
    • {@code P\t\t} — handshake, sent once the game process has been created;
    • -///
    • {@code L\t\t} — one line of game output;
    • -///
    • {@code E\t\t} — the game process has exited.
    • -///
    @NotNullByDefault final class MonitorProtocol { /// The charset of the protocol stream. static final Charset CHARSET = StandardCharsets.UTF_8; /// How long to wait for the monitor's handshake before giving up, in milliseconds. - static final long HANDSHAKE_TIMEOUT = 20_000; + static final long TIMEOUT = 20_000; private MonitorProtocol() { } - /// Writes and flushes the handshake message carrying the game process id and its start time. - static void pidMessage(PrintStream protocol, long pid, long processStartTime) { - protocol.println("P\t" + pid + "\t" + processStartTime); - } - - /// Writes and flushes a game output line message. - static void logMessage(PrintStream protocol, boolean isErrorStream, String line) { - protocol.println("L\t" + (isErrorStream ? 1 : 0) + "\t" + line); - } - - /// Writes and flushes the game exit message carrying the exit code and its classified type. - static void exitMessage(PrintStream protocol, int exitCode, ProcessListener.ExitType exitType) { - protocol.println("E\t" + exitCode + "\t" + exitType.name()); + static void readLauncherControl(BufferedReader control, Consumer commandConsumer) throws IOException { + @Nullable String line; + while ((line = control.readLine()) != null) { + try { + commandConsumer.accept(ControlCommand.valueOf(line)); + } catch (IllegalArgumentException e) { + throw new IOException("Unexpected launcher control message: " + line, e); + } + } } - static void readAll(BufferedReader reader, Consumer messageConsumer, BiConsumer err) throws IOException { - String line; + static void readMonitorMessage(BufferedReader reader, Consumer messageConsumer) throws IOException { + @Nullable String line; while ((line = reader.readLine()) != null) { try { - messageConsumer.accept(decode(line)); + messageConsumer.accept(MonitorMessage.valueOf(line)); } catch (IllegalArgumentException e) { - err.accept(line, e); + // ignore regular err messages } } } - /// @throws IOException if launcher cannot shake hand with monitor within [MonitorProtocol#HANDSHAKE_TIMEOUT] - static Pid readHandshake(BufferedReader reader, Runnable failClean) throws IOException { - CompletableFuture future = new CompletableFuture<>(); + /// @throws IOException if launcher cannot shake hand with monitor within [MonitorProtocol#TIMEOUT] + static MonitorMessage.Pid readHandshake(BufferedReader reader, Runnable failClean) throws IOException { + CompletableFuture future = new CompletableFuture<>(); Thread waiter = Lang.thread(() -> { try { - String line; + @Nullable String line; while ((line = reader.readLine()) != null) { try { - if (decode(line) instanceof Pid pid) { + if (MonitorMessage.valueOf(line) instanceof MonitorMessage.Pid pid) { future.complete(pid); return; } - throw new IOException("Unexpected handshake message from HMCL monitor: " + line); + throw new IOException("HMCL monitor sent message before handshake: " + line); } catch (IllegalArgumentException e) { // ignore regular err messages } } - throw new IOException("The HMCL monitor exited before the handshake"); + throw new IOException("The HMCL monitor exited before handshake"); } catch (Exception e) { future.completeExceptionally(e); } @@ -100,7 +88,7 @@ static Pid readHandshake(BufferedReader reader, Runnable failClean) throws IOExc boolean handshakeCompleted = false; try { - Pid handshake = future.get(HANDSHAKE_TIMEOUT, TimeUnit.MILLISECONDS); + MonitorMessage.Pid handshake = future.get(TIMEOUT, TimeUnit.MILLISECONDS); handshakeCompleted = true; return handshake; } catch (TimeoutException e) { @@ -118,32 +106,56 @@ static Pid readHandshake(BufferedReader reader, Runnable failClean) throws IOExc } } - public static Message decode(String message) throws IllegalArgumentException { - String[] parts = message.split("\t", 3); - try { - return switch (parts[0]) { - case "P" -> new Pid(Long.parseLong(parts[1]), Long.parseLong(parts[2])); - case "L" -> new Log("1".equals(parts[1]), parts[2]); - case "E" -> new Exit(Integer.parseInt(parts[1]), ProcessListener.ExitType.valueOf(parts[2])); - default -> throw new IllegalStateException("Unexpected value: " + message.charAt(0)); - }; - } catch (Exception e) { - throw new IllegalArgumentException(e); - } + /// Commands sent from the launcher to monitor stdin, serialized using their enum names. + enum ControlCommand { + /// Request cancellation before the monitor terminates the game. + CANCEL, + /// Transfer exit handling to the monitor while keeping cancellation available. + DETACH, + /// Confirm that the launcher has finished handling game exit. + HANDLED } - sealed interface Message permits Pid, Log, Exit { - } + /// Messages sent from the monitor stderr to launcher, serialized using [encode()]. + sealed interface MonitorMessage permits MonitorMessage.Pid, MonitorMessage.Log, MonitorMessage.Exit { + static MonitorMessage valueOf(String message) throws IllegalArgumentException { + String[] parts = message.split("\t", 3); + try { + return switch (parts[0]) { + case "P" -> new Pid(Long.parseLong(parts[1]), Long.parseLong(parts[2])); + case "L" -> new Log("1".equals(parts[1]), parts[2]); + case "E" -> new Exit(Integer.parseInt(parts[1]), ProcessListener.ExitType.valueOf(parts[2])); + default -> throw new IllegalStateException("Unexpected value: " + message.charAt(0)); + }; + } catch (Exception e) { + throw new IllegalArgumentException(e); + } + } - /// handshake message - record Pid(long pid, long processStartTime) implements Message { - } + String encode(); - /// game output line - record Log(boolean isErrorStream, String line) implements Message { - } + /// handshake message + record Pid(long pid, long processStartTime) implements MonitorMessage { + @Override + public String encode() { + return "P\t" + pid + "\t" + processStartTime; + } + } + + /// game output line + record Log(boolean isErrorStream, String line) implements MonitorMessage { + @Override + public String encode() { + return "L\t" + (isErrorStream ? 1 : 0) + "\t" + line; + } + } - /// game exit message - record Exit(int exitCode, ProcessListener.ExitType exitType) implements Message { + /// game exit message + record Exit(int exitCode, ProcessListener.ExitType exitType) implements MonitorMessage { + @Override + public String encode() { + return "E\t" + exitCode + "\t" + exitType.name(); + } + } } } From 3c9cfbb70cb202d07a3b81bd27079d6c8efc8370 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B1=BC=E5=89=8D=E4=B8=89=E7=99=BE?= Date: Fri, 23 Oct 2026 17:34:55 +0800 Subject: [PATCH 43/44] =?UTF-8?q?fix:=20=E8=B7=B3=E8=BF=87=20QuickPlayOpti?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/jackhuang/hmcl/monitor/MonitorLauncher.java | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java index 92efbde51c1..76607bb53c7 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/monitor/MonitorLauncher.java @@ -24,10 +24,7 @@ import org.jackhuang.hmcl.event.JVMLaunchFailedEvent; import org.jackhuang.hmcl.event.ProcessExitedAbnormallyEvent; import org.jackhuang.hmcl.event.ProcessStoppedEvent; -import org.jackhuang.hmcl.game.HMCLGameInstance; -import org.jackhuang.hmcl.game.LaunchOptions; -import org.jackhuang.hmcl.game.ProxyOption; -import org.jackhuang.hmcl.game.Renderer; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.launch.ProcessListener; import org.jackhuang.hmcl.setting.GameSettings; import org.jackhuang.hmcl.setting.LauncherVisibility; @@ -63,7 +60,9 @@ public boolean shouldSkipField(FieldAttributes f) { @Override public boolean shouldSkipClass(Class clazz) { - return ProxyOption.class.isAssignableFrom(clazz) || Renderer.class.isAssignableFrom(clazz); + return ProxyOption.class.isAssignableFrom(clazz) + || Renderer.class.isAssignableFrom(clazz) + || QuickPlayOption.class.isAssignableFrom(clazz); } }).create(); From 7e4fa6588edf65ad24a01cc6d98531a8e6156a23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B1=BC=E5=89=8D=E4=B8=89=E7=99=BE?= Date: Fri, 23 Oct 2026 18:24:17 +0800 Subject: [PATCH 44/44] =?UTF-8?q?chore:=20=E7=A7=BB=E9=99=A4=E6=97=A0?= =?UTF-8?q?=E7=94=A8=E7=9A=84=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java index d5397ac4f4b..3e9ab0390e3 100644 --- a/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java +++ b/HMCLMonitor/src/main/java/org/jackhuang/hmcl/monitor/MonitorGameProcess.java @@ -18,7 +18,6 @@ package org.jackhuang.hmcl.monitor; import org.jackhuang.hmcl.task.Task; -import org.jackhuang.hmcl.util.platform.ManagedProcess; import org.jackhuang.hmcl.util.platform.ProcessInfo; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; @@ -33,7 +32,7 @@ /// A view of the game process launched by the HMCL monitor. /// -/// Unlike [ManagedProcess], this process may not be resolved when it exited before +/// Unlike [org.jackhuang.hmcl.util.platform.ManagedProcess], this process may not be resolved when it exited before /// monitor's handshake. @NotNullByDefault public final class MonitorGameProcess implements ProcessInfo {