From 38fe5cf4dc0adc5464a5519936dfc87eab589766 Mon Sep 17 00:00:00 2001 From: 3arthqu4ke <56741599+3arthqu4ke@users.noreply.github.com> Date: Sat, 24 Aug 2024 13:29:17 +0200 Subject: [PATCH] Retry option for LaunchCommand --- .github/workflows/run-matrix-xvfb.yml | 2 +- .github/workflows/run-matrix.yml | 2 +- .../headlessmc/api/command/CommandUtil.java | 6 + .../launcher/LauncherProperties.java | 1 + .../launcher/command/LaunchCommand.java | 175 +++++++++++------- .../launcher/launch/InMemoryLauncher.java | 3 + .../launcher/launch/LaunchOptions.java | 8 +- .../headlessmc/launcher/LauncherMock.java | 15 +- .../launcher/command/LaunchCommandTest.java | 70 +++++++ .../VersionSpecificModManagerTest.java | 16 +- .../launcher/version/DummyVersion.java | 18 ++ 11 files changed, 216 insertions(+), 100 deletions(-) create mode 100644 headlessmc-launcher/src/test/java/me/earth/headlessmc/launcher/version/DummyVersion.java diff --git a/.github/workflows/run-matrix-xvfb.yml b/.github/workflows/run-matrix-xvfb.yml index dc9f311b..bf916480 100644 --- a/.github/workflows/run-matrix-xvfb.yml +++ b/.github/workflows/run-matrix-xvfb.yml @@ -96,5 +96,5 @@ jobs: java: ${{ matrix.version.java }} mc-runtime-test: ${{ matrix.version.type }} xvfb: true - headlessmc-command: --jvm -Djava.awt.headless=true + headlessmc-command: --retries 2 --jvm -Djava.awt.headless=true download-hmc: false diff --git a/.github/workflows/run-matrix.yml b/.github/workflows/run-matrix.yml index a8e860d8..0cbef5ca 100644 --- a/.github/workflows/run-matrix.yml +++ b/.github/workflows/run-matrix.yml @@ -96,5 +96,5 @@ jobs: java: ${{ matrix.version.java }} mc-runtime-test: ${{ matrix.version.type }} xvfb: false - headlessmc-command: -lwjgl --jvm -Djava.awt.headless=true + headlessmc-command: -lwjgl --retries 2 --jvm -Djava.awt.headless=true download-hmc: false diff --git a/headlessmc-api/src/main/java/me/earth/headlessmc/api/command/CommandUtil.java b/headlessmc-api/src/main/java/me/earth/headlessmc/api/command/CommandUtil.java index cf5e0ce9..caa67469 100644 --- a/headlessmc-api/src/main/java/me/earth/headlessmc/api/command/CommandUtil.java +++ b/headlessmc-api/src/main/java/me/earth/headlessmc/api/command/CommandUtil.java @@ -1,6 +1,8 @@ package me.earth.headlessmc.api.command; import lombok.experimental.UtilityClass; +import me.earth.headlessmc.api.config.HasConfig; +import me.earth.headlessmc.api.config.Property; import org.jetbrains.annotations.Nullable; import java.util.ArrayList; @@ -97,6 +99,10 @@ public static boolean hasFlag(String arg, String... args) { return Arrays.stream(args).anyMatch(s -> s.equalsIgnoreCase(arg)); } + public static boolean flag(HasConfig ctx, String flg, Property invertFlag, Property alwaysFlag, String... args) { + return ctx.getConfig().get(alwaysFlag, false) || CommandUtil.hasFlag(flg, args) ^ ctx.getConfig().get(invertFlag, false); + } + public static @Nullable String getOption(String option, String... args) { for (int i = 0; i < args.length; i++) { if (option.equalsIgnoreCase(args[i]) && i < args.length - 1) { diff --git a/headlessmc-launcher/src/main/java/me/earth/headlessmc/launcher/LauncherProperties.java b/headlessmc-launcher/src/main/java/me/earth/headlessmc/launcher/LauncherProperties.java index f08d1bf7..46f67db6 100644 --- a/headlessmc-launcher/src/main/java/me/earth/headlessmc/launcher/LauncherProperties.java +++ b/headlessmc-launcher/src/main/java/me/earth/headlessmc/launcher/LauncherProperties.java @@ -43,6 +43,7 @@ public interface LauncherProperties extends HmcProperties { Property ALWAYS_LWJGL_FLAG = bool("hmc.always.lwjgl.flag"); Property ALWAYS_PAULS_FLAG = bool("hmc.always.pauls.flag"); + Property ALWAYS_QUIT_FLAG = bool("hmc.always.quit.flag"); Property ALWAYS_JNDI_FLAG = bool("hmc.always.jndi.flag"); Property ALWAYS_LOOKUP_FLAG = bool("hmc.always.lookup.flag"); diff --git a/headlessmc-launcher/src/main/java/me/earth/headlessmc/launcher/command/LaunchCommand.java b/headlessmc-launcher/src/main/java/me/earth/headlessmc/launcher/command/LaunchCommand.java index 86ce10b5..86c17e27 100644 --- a/headlessmc-launcher/src/main/java/me/earth/headlessmc/launcher/command/LaunchCommand.java +++ b/headlessmc-launcher/src/main/java/me/earth/headlessmc/launcher/command/LaunchCommand.java @@ -4,12 +4,13 @@ import lombok.val; import me.earth.headlessmc.api.command.CommandException; import me.earth.headlessmc.api.command.CommandUtil; -import me.earth.headlessmc.api.config.Property; +import me.earth.headlessmc.api.command.ParseUtil; import me.earth.headlessmc.launcher.Launcher; import me.earth.headlessmc.launcher.LauncherProperties; import me.earth.headlessmc.launcher.auth.AuthException; import me.earth.headlessmc.launcher.auth.LaunchAccount; import me.earth.headlessmc.launcher.auth.ValidatedAccount; +import me.earth.headlessmc.launcher.files.FileManager; import me.earth.headlessmc.launcher.launch.LaunchException; import me.earth.headlessmc.launcher.launch.LaunchOptions; import me.earth.headlessmc.launcher.version.Version; @@ -18,30 +19,25 @@ import java.util.UUID; import java.util.logging.Level; +import static me.earth.headlessmc.api.command.CommandUtil.flag; import static me.earth.headlessmc.launcher.LauncherProperties.RE_THROW_LAUNCH_EXCEPTIONS; @CustomLog public class LaunchCommand extends AbstractVersionCommand { public LaunchCommand(Launcher launcher) { super(launcher, "launch", "Launches the game."); - args.put("", "Name or id of the version to launch." + - " If you use the id you need to use the -id flag as well."); - args.put("-id", - "Use if you specified an id instead of a version name."); - args.put("-commands", - "Starts the game with the built-in command line support."); - args.put("-lwjgl", "Removes lwjgl code, causing Minecraft" + - " not to render anything."); + args.put("", "Name or id of the version to launch. If you use the id you need to use the -id flag as well."); + args.put("-id", "Use if you specified an id instead of a version name."); + args.put("-commands", "Starts the game with the built-in command line support."); + args.put("-lwjgl", "Removes lwjgl code, causing Minecraft not to render anything."); args.put("-inmemory", "Launches the game in the same JVM headlessmc is running in."); args.put("-jndi", "Patches the Log4J vulnerability."); args.put("-lookup", "Patches the Log4J vulnerability even harder."); - args.put("-paulscode", "Removes some error messages from the" + - " PaulsCode library which may annoy you if you started the" + - " game with the -lwjgl flag."); - // TODO: is this really necessary? - args.put("-noout", "Doesn't print Minecrafts output to the console."); + args.put("-paulscode", "Removes some error messages from the PaulsCode library which may annoy you if you started the game with the -lwjgl flag."); + args.put("-noout", "Doesn't print Minecrafts output to the console."); // TODO: is this really necessary? args.put("-quit", "Quit HeadlessMc after launching the game."); args.put("--jvm", "Jvm args to use."); + args.put("--retries", "The amount of times you want to retry running Minecraft."); } @Override @@ -52,83 +48,38 @@ public void execute(Version version, String... args) throws CommandException { ctx.getLoggingService().setLevel(Level.INFO, true); val files = ctx.getFileManager().createRelative(uuid.toString()); - boolean quit = flag("-quit", LauncherProperties.INVERT_QUIT_FLAG, args); + boolean quit = flag(ctx, "-quit", LauncherProperties.INVERT_QUIT_FLAG, LauncherProperties.ALWAYS_QUIT_FLAG, args); int status = 0; - ClassLoader contextClassloader = Thread.currentThread().getContextClassLoader(); try { if (!prepare) { ctx.getCommandLine().close(); } - val process = ctx.getProcessFactory().run( - LaunchOptions.builder() - .account(getAccount()) - .version(version) - .launcher(ctx) - .files(files) - .parseFlags(ctx, quit, args) - .prepare(prepare) - .build()); - if (prepare) { - return; - } - - if (process == null) { - ctx.log("InMemory main thread ended."); - Thread.currentThread().setContextClassLoader(contextClassloader); - } - - if (quit || process == null) { - ctx.getExitManager().exit(0); - return; - } - - try { - status = process.waitFor(); - ctx.log("Minecraft exited with code: " + status); - if (status != 0 && ctx.getConfig().get(RE_THROW_LAUNCH_EXCEPTIONS, false)) { - throw new IllegalStateException("Minecraft exited with code " + status); - } - } catch (InterruptedException ie) { - ctx.log("Launcher has been interrupted..."); - Thread.currentThread().interrupt(); - } + status = runProcess(version, files, quit, prepare, args); } catch (IOException | LaunchException | AuthException e) { status = -1; log.error(e); - ctx.log(String.format( - "Couldn't launch %s: %s", version.getName(), e.getMessage())); + ctx.log(String.format("Couldn't launch %s: %s", version.getName(), e.getMessage())); if (ctx.getConfig().get(RE_THROW_LAUNCH_EXCEPTIONS, false)) { throw new IllegalStateException(e); } } catch (Throwable t) { status = -1; - val msg = String.format( - "Couldn't launch %s: %s", version.getName(), t.getMessage()); + val msg = String.format("Couldn't launch %s: %s", version.getName(), t.getMessage()); log.error(msg, t); ctx.log(msg); throw t; } finally { - Thread.currentThread().setContextClassLoader(contextClassloader); - // for some reason both ShutdownHooks and File.deleteOnExit are - // not really working, that's why we Main.deleteOldFiles, too. - if (!CommandUtil.hasFlag("-keep", args) - && !ctx.getConfig().get(LauncherProperties.KEEP_FILES, false)) { - try { - log.info("Deleting " + files.getBase().getName()); - ctx.getFileManager().delete(files.getBase()); - } catch (IOException e) { - log.error("Couldn't delete files of game " - + files.getBase().getName() - + ": " + e.getMessage()); - } - } - + cleanup(files, args); if (!prepare && !CommandUtil.hasFlag("-stay", args)) { ctx.getExitManager().exit(status); } } + if (status != 0 && ctx.getConfig().get(RE_THROW_LAUNCH_EXCEPTIONS, false)) { + throw new IllegalStateException("Minecraft exited with code " + status); + } + if (!prepare) { try { ctx.getCommandLine().open(ctx); @@ -138,8 +89,92 @@ public void execute(Version version, String... args) throws CommandException { } } - private boolean flag(String flg, Property inv, String... args) { - return CommandUtil.hasFlag(flg, args) ^ ctx.getConfig().get(inv, false); + private int runProcess(Version version, FileManager files, boolean quit, boolean prepare, String... args) + throws CommandException, LaunchException, AuthException { + int status = 0; + LaunchAccount account = getAccount(); + String retriesOption = CommandUtil.getOption("--retries", args); + int retries = 0; + if (retriesOption != null) { + retries = ParseUtil.parseI(retriesOption); + } + + Throwable throwable = null; + for (int i = 0; i < retries + 1; i++) { + if (i > 0) { + log.warn("Retrying to launch Minecraft: " + i); + } + + try { + Process process = ctx.getProcessFactory().run( + LaunchOptions.builder() + .account(account) + .version(version) + .launcher(ctx) + .files(files) + .parseFlags(ctx, quit, args) + .prepare(prepare) + .build() + ); + + if (prepare) { + return 0; + } + + if (process == null) { + ctx.log("InMemory main thread ended."); + } + + if (quit || process == null) { + cleanup(files, args); + ctx.getExitManager().exit(0); + return 0; + } + + try { + status = process.waitFor(); + ctx.log("Minecraft exited with code: " + status); + } catch (InterruptedException ie) { + ctx.log("Launcher has been interrupted..."); + Thread.currentThread().interrupt(); + } + + if (status == 0) { + break; + } + } catch (Throwable t) { + status = -1; + log.error("Failed to start Minecraft on try " + i, t); + if (throwable == null) { + throwable = t; + } else { + throwable.addSuppressed(t); + } + } + } + + if (status != 0 && throwable != null) { + if (throwable instanceof RuntimeException) { + throw (RuntimeException) throwable; + } + + throw new LaunchException(throwable); + } + + return status; + } + + private void cleanup(FileManager files, String... args) { + // for some reason both ShutdownHooks and File.deleteOnExit are + // not really working, that's why we Main.deleteOldFiles, too. + if (!CommandUtil.hasFlag("-keep", args) && !ctx.getConfig().get(LauncherProperties.KEEP_FILES, false)) { + try { + log.info("Deleting " + files.getBase().getName()); + ctx.getFileManager().delete(files.getBase()); + } catch (IOException e) { + log.error("Couldn't delete files of game " + files.getBase().getName(), e); + } + } } protected LaunchAccount getAccount() throws CommandException { diff --git a/headlessmc-launcher/src/main/java/me/earth/headlessmc/launcher/launch/InMemoryLauncher.java b/headlessmc-launcher/src/main/java/me/earth/headlessmc/launcher/launch/InMemoryLauncher.java index baf0c418..216f215b 100644 --- a/headlessmc-launcher/src/main/java/me/earth/headlessmc/launcher/launch/InMemoryLauncher.java +++ b/headlessmc-launcher/src/main/java/me/earth/headlessmc/launcher/launch/InMemoryLauncher.java @@ -86,6 +86,7 @@ public void launch() throws IOException, LaunchException, AuthException { } protected void java9Launch(URL[] classpathUrls, String mainClass, List gameArgs) { + ClassLoader contextClassloader = Thread.currentThread().getContextClassLoader(); try { Class bootstrapLauncherClass = Class.forName("me.earth.headlessmc.modlauncher.LayeredBootstrapLauncher"); Constructor constructor = bootstrapLauncherClass.getConstructor(List.class, URL[].class, String.class); @@ -94,6 +95,8 @@ protected void java9Launch(URL[] classpathUrls, String mainClass, List g launch.invoke(bootstrapLauncher, (Object) gameArgs.toArray(new String[0])); } catch (ReflectiveOperationException e) { throw new IllegalStateException(e); + } finally { + Thread.currentThread().setContextClassLoader(contextClassloader); } } diff --git a/headlessmc-launcher/src/main/java/me/earth/headlessmc/launcher/launch/LaunchOptions.java b/headlessmc-launcher/src/main/java/me/earth/headlessmc/launcher/launch/LaunchOptions.java index 4526bdd9..8b1de291 100644 --- a/headlessmc-launcher/src/main/java/me/earth/headlessmc/launcher/launch/LaunchOptions.java +++ b/headlessmc-launcher/src/main/java/me/earth/headlessmc/launcher/launch/LaunchOptions.java @@ -4,10 +4,7 @@ import lombok.CustomLog; import lombok.Data; import me.earth.headlessmc.api.command.CommandUtil; -import me.earth.headlessmc.api.config.HasConfig; -import me.earth.headlessmc.api.config.Property; import me.earth.headlessmc.launcher.Launcher; -import me.earth.headlessmc.launcher.LauncherProperties; import me.earth.headlessmc.launcher.auth.LaunchAccount; import me.earth.headlessmc.launcher.files.FileManager; import me.earth.headlessmc.launcher.version.Version; @@ -17,6 +14,7 @@ import java.util.Collections; import java.util.List; +import static me.earth.headlessmc.api.command.CommandUtil.flag; import static me.earth.headlessmc.launcher.LauncherProperties.*; @Data @@ -78,10 +76,6 @@ public LaunchOptionsBuilder parseJvmArgs(String... args) { return this; } - - private boolean flag(HasConfig ctx, String flg, Property invertFlag, Property alwaysFlag, String... args) { - return ctx.getConfig().get(alwaysFlag, false) || CommandUtil.hasFlag(flg, args) ^ ctx.getConfig().get(invertFlag, false); - } } } diff --git a/headlessmc-launcher/src/test/java/me/earth/headlessmc/launcher/LauncherMock.java b/headlessmc-launcher/src/test/java/me/earth/headlessmc/launcher/LauncherMock.java index c58fe83d..a34b4ec6 100644 --- a/headlessmc-launcher/src/test/java/me/earth/headlessmc/launcher/LauncherMock.java +++ b/headlessmc-launcher/src/test/java/me/earth/headlessmc/launcher/LauncherMock.java @@ -7,10 +7,7 @@ import me.earth.headlessmc.api.config.ConfigImpl; import me.earth.headlessmc.api.config.HasConfig; import me.earth.headlessmc.api.exit.ExitManager; -import me.earth.headlessmc.launcher.auth.AccountManager; -import me.earth.headlessmc.launcher.auth.AccountStore; -import me.earth.headlessmc.launcher.auth.AccountValidator; -import me.earth.headlessmc.launcher.auth.ValidatedAccount; +import me.earth.headlessmc.launcher.auth.*; import me.earth.headlessmc.launcher.download.ChecksumService; import me.earth.headlessmc.launcher.download.DownloadService; import me.earth.headlessmc.launcher.download.MockDownloadService; @@ -33,6 +30,10 @@ public class LauncherMock { public static final Launcher INSTANCE; static { + INSTANCE = create(); + } + + public static Launcher create() { val base = FileManager.forPath("build"); val fileManager = base.createRelative("fileManager"); val configs = new ConfigService(fileManager); @@ -50,12 +51,14 @@ public class LauncherMock { DownloadService downloadService = new MockDownloadService(); val versionSpecificModManager = new VersionSpecificModManager(downloadService, fileManager.createRelative("specifics")); - INSTANCE = new Launcher(hmc, versions, mcFiles, mcFiles, + Launcher launcher = new Launcher(hmc, versions, mcFiles, mcFiles, new ChecksumService(), new MockDownloadService(), fileManager, new MockProcessFactory(downloadService, mcFiles, configs, os), configs, javas, accounts, versionSpecificModManager, new PluginManager()); - INSTANCE.getConfigService().setConfig(ConfigImpl.empty()); + launcher.getConfigService().setConfig(ConfigImpl.empty()); + + return launcher; } private static final class DummyAccountManager extends AccountManager { diff --git a/headlessmc-launcher/src/test/java/me/earth/headlessmc/launcher/command/LaunchCommandTest.java b/headlessmc-launcher/src/test/java/me/earth/headlessmc/launcher/command/LaunchCommandTest.java index ac626e4f..dbbea926 100644 --- a/headlessmc-launcher/src/test/java/me/earth/headlessmc/launcher/command/LaunchCommandTest.java +++ b/headlessmc-launcher/src/test/java/me/earth/headlessmc/launcher/command/LaunchCommandTest.java @@ -1,9 +1,19 @@ package me.earth.headlessmc.launcher.command; import me.earth.headlessmc.api.command.CommandException; +import me.earth.headlessmc.launcher.Launcher; import me.earth.headlessmc.launcher.LauncherMock; +import me.earth.headlessmc.launcher.launch.LaunchException; +import me.earth.headlessmc.launcher.launch.LaunchOptions; +import me.earth.headlessmc.launcher.launch.ProcessFactory; +import me.earth.headlessmc.launcher.version.DummyVersion; +import me.earth.headlessmc.launcher.version.Version; +import org.jetbrains.annotations.Nullable; import org.junit.jupiter.api.Test; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; public class LaunchCommandTest { @@ -14,4 +24,64 @@ public void testLaunchCommand() { // More extensive testing is done in the launch.LaunchTest } + @Test + public void testLaunchCommandRetry() throws CommandException { + Launcher launcher = LauncherMock.create(); + MockProcessFactory mockProcessFactory = new MockProcessFactory(launcher); + launcher.setProcessFactory(mockProcessFactory); + LaunchCommand command = new LaunchCommand(launcher); + Version version = new DummyVersion("1.12.2-forge", new DummyVersion("1.12.2", null)); + AtomicInteger exitCode = new AtomicInteger(); + launcher.getExitManager().setExitManager(exitCode::set); + assertEquals(0, mockProcessFactory.runs); + + command.execute(version, "launch", version.getName()); + assertEquals(1, mockProcessFactory.runs); + assertEquals(-1, exitCode.get()); + mockProcessFactory.runs = 0; + exitCode.set(0); + + command.execute(version, "launch", version.getName(), "--retries", "0"); + assertEquals(1, mockProcessFactory.runs); + assertEquals(-1, exitCode.get()); + mockProcessFactory.runs = 0; + exitCode.set(0); + + command.execute(version, "launch", version.getName(), "--retries", "1"); + assertEquals(2, mockProcessFactory.runs); + assertEquals(-1, exitCode.get()); + mockProcessFactory.runs = 0; + exitCode.set(0); + + command.execute(version, "launch", version.getName(), "--retries", "4"); + assertEquals(5, mockProcessFactory.runs); + assertEquals(-1, exitCode.get()); + mockProcessFactory.runs = 0; + exitCode.set(0); + + mockProcessFactory.dontFailOnRun = 4; + command.execute(version, "launch", version.getName(), "--retries", "4"); + assertEquals(4, mockProcessFactory.runs); + assertEquals(0, exitCode.get()); + } + + private static class MockProcessFactory extends ProcessFactory { + private Integer dontFailOnRun; + private int runs = 0; + + public MockProcessFactory(Launcher launcher) { + super(launcher.getDownloadService(), launcher.getMcFiles(), launcher.getConfigService(), launcher.getProcessFactory().getOs()); + } + + @Override + public @Nullable Process run(LaunchOptions options) throws LaunchException { + runs++; + if (dontFailOnRun != null && runs == dontFailOnRun) { + return null; + } + + throw new LaunchException("Mock Factory"); + } + } + } diff --git a/headlessmc-launcher/src/test/java/me/earth/headlessmc/launcher/specifics/VersionSpecificModManagerTest.java b/headlessmc-launcher/src/test/java/me/earth/headlessmc/launcher/specifics/VersionSpecificModManagerTest.java index 8c39ccb2..bc0e894e 100644 --- a/headlessmc-launcher/src/test/java/me/earth/headlessmc/launcher/specifics/VersionSpecificModManagerTest.java +++ b/headlessmc-launcher/src/test/java/me/earth/headlessmc/launcher/specifics/VersionSpecificModManagerTest.java @@ -1,10 +1,8 @@ package me.earth.headlessmc.launcher.specifics; -import lombok.Getter; import me.earth.headlessmc.launcher.download.DownloadService; import me.earth.headlessmc.launcher.files.FileManager; -import me.earth.headlessmc.launcher.launch.DelegatingVersion; -import me.earth.headlessmc.launcher.version.Version; +import me.earth.headlessmc.launcher.version.DummyVersion; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; @@ -20,16 +18,4 @@ public void testVersionSpecificModManagerTest() throws VersionSpecificException, modManager.download(new DummyVersion("1.12.2-forge", new DummyVersion("1.12.2", null)), VersionSpecificMods.HMC_SPECIFICS); } - @Getter - private static class DummyVersion extends DelegatingVersion { - private final String name; - private final Version parent; - - public DummyVersion(String name, Version parent) { - super(null); - this.name = name; - this.parent = parent; - } - } - } diff --git a/headlessmc-launcher/src/test/java/me/earth/headlessmc/launcher/version/DummyVersion.java b/headlessmc-launcher/src/test/java/me/earth/headlessmc/launcher/version/DummyVersion.java new file mode 100644 index 00000000..025600ce --- /dev/null +++ b/headlessmc-launcher/src/test/java/me/earth/headlessmc/launcher/version/DummyVersion.java @@ -0,0 +1,18 @@ +package me.earth.headlessmc.launcher.version; + + +import lombok.Getter; +import me.earth.headlessmc.launcher.launch.DelegatingVersion; + +@Getter +public class DummyVersion extends DelegatingVersion { + private final String name; + private final Version parent; + + public DummyVersion(String name, Version parent) { + super(null); + this.name = name; + this.parent = parent; + } + +}