diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 000000000..5269f1203 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/build.gradle b/build.gradle index bccaeaaf0..0d43ab06a 100644 --- a/build.gradle +++ b/build.gradle @@ -168,6 +168,9 @@ configurations.matching({ it =~ ~/test(Runtime|Compile|Implementation)Classpath/ sourceCompatibility = 1.11 targetCompatibility = 1.11 +compileJava.options.encoding = "UTF-8" +compileTestJava.options.encoding = "UTF-8" + javafx { version = "12.0.1" modules = [ diff --git a/src/main/java/org/terasology/launcher/game/GameRunner.java b/src/main/java/org/terasology/launcher/game/GameRunner.java deleted file mode 100644 index 1a16cc658..000000000 --- a/src/main/java/org/terasology/launcher/game/GameRunner.java +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright 2016 MovingBlocks - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.terasology.launcher.game; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; -import java.nio.charset.Charset; - -public class GameRunner implements Runnable { - - private static final Logger logger = LoggerFactory.getLogger(GameRunner.class); - - private final Process p; - - public GameRunner(final Process p) { - this.p = p; - } - - @Override - public void run() { - try { - try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream(), Charset.defaultCharset()))) { - String line; - do { - line = r.readLine(); - logger.trace("Game output: {}", line); - } while (!isInterrupted() && line != null); - } - if (isInterrupted()) { - logger.debug("Game thread interrupted."); - return; - } - int exitValue = -1; - try { - exitValue = p.waitFor(); - } catch (InterruptedException e) { - logger.error("The game thread was interrupted!", e); - } - logger.debug("Game closed with the exit value '{}'.", exitValue); - } catch (IOException e) { - logger.error("Could not read game output!", e); - } - } - - boolean isInterrupted() { - return Thread.currentThread().isInterrupted(); - } -} diff --git a/src/main/java/org/terasology/launcher/game/GameService.java b/src/main/java/org/terasology/launcher/game/GameService.java new file mode 100644 index 000000000..d779887c3 --- /dev/null +++ b/src/main/java/org/terasology/launcher/game/GameService.java @@ -0,0 +1,164 @@ +/* + * Copyright 2020 MovingBlocks + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.terasology.launcher.game; + +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import javafx.concurrent.Service; +import javafx.concurrent.Worker; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.terasology.launcher.settings.BaseLauncherSettings; + +import java.nio.file.Path; +import java.util.concurrent.Executors; + +import static com.google.common.base.Verify.verifyNotNull; + +/** + * This service starts and monitors the game process. + *

+ * Its {@linkplain #GameService() constructor} requires no arguments. Use {@link #start(Path, BaseLauncherSettings)} to + * start the game process; the zero-argument form of {@code start()} will not have enough information. + *

+ * The Boolean value of this service is true when it believes the game process has started successfully. + * There will be some time between when this service is started and when that value is set. It can be observed on + * {@link #valueProperty()} or retrieved as {@link #getValue()}. + *

+ * The {@link Worker} interface of this Service provides a proxy to its current {@link RunGameTask}. + * Many of the methods the Worker interface defines are not used by this task type. In particular, the + * {@link Worker#progressProperty() progress} and {@link Worker#workDoneProperty() workDone} properties have no + * information for you that reflect the state of the game process. + *

+ * This service will be in {@link State#RUNNING RUNNING} state as long as the game process is live. It enters + * {@link State#SUCCEEDED SUCCEEDED} after the game process exits with no error code, or {@link State#FAILED FAILED} if + * the game failed to start or it terminates with an error. Whether it succeeded or failed, the service will then + * reset to {@link State#READY READY} to be started again. + *

+ * This service {@linkplain #cancel() does not support cancellation}. + *

+ */ +public class GameService extends Service { + private static final Logger logger = LoggerFactory.getLogger(GameService.class); + + private Path gamePath; + private BaseLauncherSettings settings; + + public GameService() { + setExecutor(Executors.newSingleThreadExecutor( + new ThreadFactoryBuilder() + .setNameFormat("GameService-%d") + .setDaemon(true) + .setUncaughtExceptionHandler(this::exceptionHandler) + .build() + )); + } + + /** + * Start a new game process with these settings. + * + * @param gamePath the directory under which we will find libs/Teresology.jar, also used as the process's + * working directory + * @param settings supplies other settings relevant to configuring a process + */ + @SuppressWarnings("checkstyle:HiddenField") + public void start(Path gamePath, BaseLauncherSettings settings) { + this.gamePath = gamePath; + this.settings = settings; + + start(); + } + + /** + * Use {@link #start(Path, BaseLauncherSettings)} instead. + *

+ * It is an error to call this method before providing the configuration. + */ + @Override + public void start() { + super.start(); + } + + /** + * Cancellation is unsupported. Do not attempt this method. + *

+ * Rationale: We do not terminate a running game process, and we don't want to lose our thread keeping track of + * the process while it's still live. If we did, our β€œis a game already running?” logic would fail. + *

+ * If you are using this with some kind of generic Service Manager that always invokes this method as part of an + * orderly shutdown, that could be a good reason to change this from throwing an exception to logging a warning. + * Until then, this fails loudly so you won't call this method thinking it does something and then become confused + * when nothing happens. + * + * @throws UnsupportedOperationException always + */ + @Override + public boolean cancel() { + throw new UnsupportedOperationException("GameService does not cancel."); + } + + /** + * Restarting is unsupported. Do not attempt this method. + *

+ * See {@link #cancel()} for rationale. + */ + @Override + public void restart() { + super.restart(); + } + + /** + * Creates a new task to run the game with the current settings. + *

+ * This class's configuration fields must be set before this is called. + * + * @throws com.google.common.base.VerifyException when fields are unset + */ + @Override + protected RunGameTask createTask() { + verifyNotNull(settings); + var starter = new GameStarter(verifyNotNull(gamePath), settings.getGameDataDirectory(), + settings.getMaxHeapSize(), settings.getInitialHeapSize(), + settings.getUserJavaParameterList(), settings.getUserGameParameterList(), + settings.getLogLevel()); + return new RunGameTask(starter); + } + + /** After a task completes, reset to ready for the next. */ + @Override + protected void succeeded() { + reset(); // Ready to go again! + } + + /** Checks to see if the failure left any exceptions behind, then resets to ready. */ + @Override + protected void failed() { + // "Uncaught" exceptions from javafx's Task are actually caught and kept in a property, + // so if we want them logged we have to explicitly dig them out. + var error = getException(); + if (error != null) { + exceptionHandler(Thread.currentThread(), error); + } + reset(); // Ready to try again! + } + + private void exceptionHandler(Thread thread, Throwable thrown) { + logger.error("Unhandled exception", thrown); + } +} diff --git a/src/main/java/org/terasology/launcher/game/GameStarter.java b/src/main/java/org/terasology/launcher/game/GameStarter.java index 20d751413..ee46252dc 100644 --- a/src/main/java/org/terasology/launcher/game/GameStarter.java +++ b/src/main/java/org/terasology/launcher/game/GameStarter.java @@ -1,11 +1,11 @@ /* - * Copyright 2016 MovingBlocks + * Copyright 2020 MovingBlocks * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, @@ -16,103 +16,73 @@ package org.terasology.launcher.game; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.slf4j.event.Level; -import org.terasology.launcher.packages.Package; import org.terasology.launcher.util.JavaHeapSize; import java.io.IOException; import java.nio.file.Path; +import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.Callable; -public final class GameStarter { - - private static final Logger logger = LoggerFactory.getLogger(GameStarter.class); - - private static final int PROCESS_START_SLEEP_TIME = 5000; - - private Thread gameThread; - - public GameStarter() { - } - - public boolean isRunning() { - return gameThread != null && gameThread.isAlive(); - } - - public void dispose() { - if (gameThread != null) { - gameThread.interrupt(); - } - gameThread = null; - } - - public boolean startGame(Package gamePkg, Path gamePath, Path gameDataDirectory, JavaHeapSize maxHeapSize, - JavaHeapSize initialHeapSize, List userJavaParameters, List userGameParameters, Level logLevel) { - if (isRunning()) { - logger.warn("The game can not be started because another game is already running! '{}'", gameThread); - return false; - } - - final List javaParameters = createJavaParameters(maxHeapSize, initialHeapSize, userJavaParameters, logLevel); - final List processParameters = createProcessParameters(gamePath, gameDataDirectory, javaParameters, userGameParameters); - - return startProcess(gamePkg, gamePath, processParameters); - } +/** + * Takes the game and runtime options, provides something that will launch a process. + * + * @see java command manual + */ +class GameStarter implements Callable { + final ProcessBuilder processBuilder; + + /** + * @param gamePath the directory under which we will find {@code libs/Terasology.jar}, also used as the process's + * working directory + * @param gameDataDirectory {@code -homedir}, the directory where Terasology's data files (saves & etc) are kept + * @param heapMin java's {@code -Xms} + * @param heapMax java's {@code -Xmx} + * @param javaParams additional arguments for the {@code java} command line + * @param gameParams additional arguments for the Terasology command line + * @param logLevel the minimum level of log events Terasology will include on its output stream to us + */ + GameStarter(Path gamePath, Path gameDataDirectory, JavaHeapSize heapMin, JavaHeapSize heapMax, List javaParams, List gameParams, + Level logLevel) { + final List processParameters = new ArrayList<>(); + processParameters.add(getRuntimePath().toString()); - private List createJavaParameters(JavaHeapSize maxHeapSize, JavaHeapSize initialHeapSize, List userJavaParameters, Level logLevel) { - final List javaParameters = new ArrayList<>(); - if (initialHeapSize.isUsed()) { - javaParameters.add("-Xms" + initialHeapSize.getSizeParameter()); + if (heapMin.isUsed()) { + processParameters.add("-Xms" + heapMin.getSizeParameter()); } - if (maxHeapSize.isUsed()) { - javaParameters.add("-Xmx" + maxHeapSize.getSizeParameter()); + if (heapMax.isUsed()) { + processParameters.add("-Xmx" + heapMax.getSizeParameter()); } - javaParameters.add("-DlogOverrideLevel=" + logLevel.toString()); - javaParameters.addAll(userJavaParameters); - return javaParameters; - } + processParameters.add("-DlogOverrideLevel=" + logLevel.name()); + processParameters.addAll(javaParams); - private List createProcessParameters(Path gamePath, Path gameDataDirectory, List javaParameters, - List gameParameters) { - final List processParameters = new ArrayList<>(); - processParameters.add(System.getProperty("java.home") + "/bin/java"); // Use the current java - processParameters.addAll(javaParameters); processParameters.add("-jar"); - processParameters.add(gamePath.resolve("libs/Terasology.jar").toString()); + processParameters.add(gamePath.resolve(Path.of("libs", "Terasology.jar")).toString()); processParameters.add("-homedir=" + gameDataDirectory.toAbsolutePath().toString()); - processParameters.addAll(gameParameters); + processParameters.addAll(gameParams); - return processParameters; + processBuilder = new ProcessBuilder(processParameters) + .directory(gamePath.toFile()) + .redirectErrorStream(true); } - private boolean startProcess(Package gamePkg, Path gamePath, List processParameters) { - final ProcessBuilder pb = new ProcessBuilder(processParameters); - pb.redirectErrorStream(true); - pb.directory(gamePath.toFile()); - logger.debug("Starting game process with '{}' in '{}' for '{}-{}'", processParameters, gamePath.toFile(), gamePkg.getId(), gamePkg.getVersion()); - try { - final Process p = pb.start(); - - gameThread = new Thread(new GameRunner(p)); - gameThread.setName("game" + gamePkg.getId() + "-" + gamePkg.getVersion()); - gameThread.start(); - - Thread.sleep(PROCESS_START_SLEEP_TIME); + /** + * Start the game in a new process. + * + * @return the newly started process + * @throws IOException from {@link ProcessBuilder#start()} + */ + @Override + public Process call() throws IOException { + return processBuilder.start(); + } - if (!gameThread.isAlive()) { - final int exitValue = p.waitFor(); - logger.warn("The game was stopped early. It returns with the exit value '{}'.", exitValue); - return false; - } else { - logger.info("The game is successfully launched."); - } - } catch (InterruptedException | IOException | RuntimeException e) { - logger.error("The game could not be started due to an error! Parameters '{}' for '{}-{}'!", processParameters, gamePkg.getId(), gamePkg.getVersion(), e); - return false; - } - return true; + /** + * @return the executable {@code java} file to run the game with + */ + Path getRuntimePath() { + return Paths.get(System.getProperty("java.home"), "bin", "java"); } } diff --git a/src/main/java/org/terasology/launcher/game/RunGameTask.java b/src/main/java/org/terasology/launcher/game/RunGameTask.java new file mode 100644 index 000000000..613beddb3 --- /dev/null +++ b/src/main/java/org/terasology/launcher/game/RunGameTask.java @@ -0,0 +1,247 @@ +/* + * Copyright 2020 MovingBlocks + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.terasology.launcher.game; + +import com.google.common.base.MoreObjects; +import javafx.concurrent.Task; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.event.Level; +import org.terasology.launcher.gui.javafx.FxTimer; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.time.Duration; +import java.util.concurrent.Callable; +import java.util.function.Predicate; +import java.util.regex.Pattern; + +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Verify.verify; +import static com.google.common.base.Verify.verifyNotNull; + +/** + * Starts and manages a game process. + *

+ * Many of the characteristics of this task are described in {@link GameService}, as it's the expected access point to + * this. + *

+ * (An individual {@link Task} lasts as long as the process does, the {@link javafx.concurrent.Service Service} + * provides a stable reference that doesn't have to be updated with each new task.) + *

+ * Beware javafx's treatment of exceptions that come up in {@link Task Tasks}. An uncaught exception will be stored + * in its {@link #exceptionProperty()} and never see its way to an executor's uncaught exception handler. It won't be + * until you call this object's {@link #get()} that it will be thrown again. + */ +class RunGameTask extends Task { + static final int EXIT_CODE_OK = 0; + + /** + * The output of the process is tested against this for a sign that it launched successfully. + *

+ * (We don't yet have a formal protocol about this with Terasology, so it won't show up if its minimum log level is + * higher than {@link Level#INFO INFO}. But the default is to include {@link Level#INFO INFO}, so this should often + * work.) + */ + static final Predicate START_MATCH = Pattern.compile("TerasologyEngine.+Initialization completed") + .asPredicate(); + + /** + * How long a process has to live in order for us to call it successful. + *

+ * If there's no output with {@link #START_MATCH} and it hasn't crashed after being up this long, it's probably + * okay. + */ + static final Duration SURVIVAL_THRESHOLD = Duration.ofSeconds(10); + + private static final Logger logger = LoggerFactory.getLogger(RunGameTask.class); + + protected final Callable starter; + + /** + * Indicates whether we have set the {@link Task#updateValue value} of this Task yet. + *

+ * The value is stored in a {@link javafx.beans.property.SimpleObjectProperty property} we can't directly + * access from this task's thread, so it remembers it here. + */ + private boolean valueSet; + + private FxTimer successTimer; + + /** + * @param starter called as soon as the Task starts to start a new process + */ + RunGameTask(Callable starter) { + this.starter = starter; + } + + /** + * Starts the process, returns when it's done. + * + * @return true when the process exits with no error code + * @throws GameStartError if the process failed to start at all + * @throws GameExitError if the process terminates with an error code + * @throws GameExitError if the process quit before {@link #SURVIVAL_THRESHOLD} + * @throws InterruptedException if this thread was interrupted while waiting for something β€” + * doesn't come up as much as you might expect, because waiting on a {@code read} call + * of the process's output can not be interrupted (Java's rule, not ours) + */ + @Override + protected Boolean call() throws GameStartError, GameExitError, InterruptedException, GameExitTooSoon { + verifyNotNull(this.starter); + verify(!this.isDone()); + Process process; + try { + process = this.starter.call(); + } catch (Exception e) { + throw new GameStartError(e); + } + monitorProcess(process); + return true; + } + + /** + * Monitors the output and exit status of a process. + * + * @param process the running process + */ + void monitorProcess(Process process) throws InterruptedException, GameExitError, GameExitTooSoon { + checkNotNull(process); + logger.debug("Game process is {}", process); + updateMessage("Game running as process " + process.pid()); + + startTimer(); + + // log each line of process output + var gameOutput = new BufferedReader(new InputStreamReader(process.getInputStream())); + gameOutput.lines().forEachOrdered(this::handleOutputLine); + + try { + // The output has closed, so we _often_ have the exit value immediately, but apparently + // not always β€” the tests were flaky. To be safe, waitFor. + var exitValue = process.waitFor(); + logger.debug("Game closed with the exit value '{}'.", exitValue); + + if (exitValue == EXIT_CODE_OK) { + updateMessage("Process complete."); + } else { + updateMessage("Process exited with code " + exitValue); + throw new GameExitError(exitValue); + } + } catch (InterruptedException e) { + logger.warn("Interrupted while waiting for game process exit.", e); + throw e; + } + + if (successTimer != null) { + // No error code, but the game quit before our timer went off? That doesn't + // seem right! + removeTimer(); + throw new GameExitTooSoon(); + } + } + + /** + * Called with each line of the process's output. + *

+ * Expect the Process has {@linkplain ProcessBuilder#redirectErrorStream() merged output and error streams}. + * + * @param line a line of output, decoded to String, with trailing newline stripped + */ + protected void handleOutputLine(String line) { + if ((!valueSet) && START_MATCH.test(line)) { + declareSurvival(); + } + logger.info("Game output: {}", line); + } + + private void declareSurvival() { + valueSet = true; + this.updateValue(true); + removeTimer(); + } + + private void timerComplete() { + logger.debug("Process has been alive at least {}, calling it good.", SURVIVAL_THRESHOLD); + declareSurvival(); + } + + protected void startTimer() { + successTimer = FxTimer.runLater(SURVIVAL_THRESHOLD, this::timerComplete); + } + + protected void removeTimer() { + if (successTimer != null) { + successTimer.stop(); + successTimer = null; + } + } + + @Override + protected void failed() { + removeTimer(); + } + + public abstract static class RunGameError extends Exception { + } + + /** + * The process failed to start. + *

+ * Check its {@link #getCause() cause}. + */ + public static class GameStartError extends RunGameError { + GameStartError(final Exception e) { + super(); + this.initCause(e); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this).addValue(this.getCause()).toString(); + } + } + + /** + * The process quit with an {@linkplain #exitValue error code}. + *

+ * These codes are platform-dependent. All know for sure is that it is not {@link #EXIT_CODE_OK}. + */ + public static class GameExitError extends RunGameError { + public final int exitValue; + + GameExitError(final int exitValue) { + this.exitValue = exitValue; + } + + @Override + public String getMessage() { + return toString(); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this).add("exitValue", exitValue).toString(); + } + } + + /** + * The process only lasted a brief time. + */ + public static class GameExitTooSoon extends RunGameError { + } +} diff --git a/src/main/java/org/terasology/launcher/gui/javafx/ApplicationController.java b/src/main/java/org/terasology/launcher/gui/javafx/ApplicationController.java index c79e9ead4..70165fac0 100644 --- a/src/main/java/org/terasology/launcher/gui/javafx/ApplicationController.java +++ b/src/main/java/org/terasology/launcher/gui/javafx/ApplicationController.java @@ -1,5 +1,5 @@ /* - * Copyright 2019 MovingBlocks + * Copyright 2020 MovingBlocks * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,8 +19,10 @@ import javafx.animation.Transition; import javafx.beans.property.Property; import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.value.ObservableValue; import javafx.collections.FXCollections; import javafx.collections.ObservableList; +import javafx.concurrent.WorkerStateEvent; import javafx.fxml.FXML; import javafx.fxml.FXMLLoader; import javafx.geometry.Insets; @@ -34,6 +36,8 @@ import javafx.scene.control.Label; import javafx.scene.control.ListCell; import javafx.scene.control.ProgressBar; +import javafx.scene.control.Tab; +import javafx.scene.control.TabPane; import javafx.scene.control.Tooltip; import javafx.scene.image.Image; import javafx.scene.image.ImageView; @@ -46,7 +50,7 @@ import javafx.stage.StageStyle; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.terasology.launcher.game.GameStarter; +import org.terasology.launcher.game.GameService; import org.terasology.launcher.packages.Package; import org.terasology.launcher.packages.PackageManager; import org.terasology.launcher.settings.BaseLauncherSettings; @@ -75,7 +79,7 @@ public class ApplicationController { private Path launcherDirectory; private BaseLauncherSettings launcherSettings; private PackageManager packageManager; - private GameStarter gameStarter; + private final GameService gameService; private Stage stage; private final ExecutorService executor = Executors.newSingleThreadExecutor(); @@ -120,6 +124,9 @@ public class ApplicationController { public ApplicationController() { warning = new SimpleObjectProperty<>(Optional.empty()); + gameService = new GameService(); + gameService.setOnFailed(this::handleRunFailed); + gameService.valueProperty().addListener(this::handleRunStarted); } @FXML @@ -179,33 +186,49 @@ protected void openSettingsAction() { } private void startGameAction() { - final Path gamePath = packageManager.resolveInstallDir(selectedPackage); - - if (gameStarter.isRunning()) { + if (gameService.isRunning()) { logger.debug("The game can not be started because another game is already running."); Dialogs.showInfo(stage, BundleUtils.getLabel("message_information_gameRunning")); } else { - final boolean gameStarted = gameStarter.startGame(selectedPackage, gamePath, launcherSettings.getGameDataDirectory(), launcherSettings.getMaxHeapSize(), - launcherSettings.getInitialHeapSize(), launcherSettings.getUserJavaParameterList(), - launcherSettings.getUserGameParameterList(), launcherSettings.getLogLevel()); - if (!gameStarted) { - Dialogs.showError(stage, BundleUtils.getLabel("message_error_gameStart")); + final Path gamePath = packageManager.resolveInstallDir(selectedPackage); + + gameService.start(gamePath, launcherSettings); + } + } + + private void handleRunStarted(ObservableValue o, Boolean oldValue, Boolean newValue) { + if (newValue == null || !newValue) { + return; + } + + logger.debug("Game has started successfully."); + + launcherSettings.setLastPlayedGameJob(selectedPackage.getId()); + launcherSettings.setLastPlayedGameVersion(selectedPackage.getVersion()); + + if (launcherSettings.isCloseLauncherAfterGameStart()) { + if (downloadTask == null) { + logger.info("Close launcher after game start."); + close(); } else { - launcherSettings.setLastPlayedGameJob(selectedPackage.getId()); - launcherSettings.setLastPlayedGameVersion(selectedPackage.getVersion()); - - if (launcherSettings.isCloseLauncherAfterGameStart()) { - if (downloadTask == null) { - logger.info("Close launcher after game start."); - close(); - } else { - logger.info("The launcher can not be closed after game start, because a download is running."); - } - } + logger.info("The launcher can not be closed after game start, because a download is running."); } } } + void handleRunFailed(WorkerStateEvent event) { + TabPane tabPane = (TabPane) stage.getScene().lookup("#contentTabPane"); + if (tabPane != null) { + var tab = tabPane.lookup("#logTab"); + tabPane.getSelectionModel().select((Tab) tab.getProperties().get(Tab.class)); + } else { + // We're already in error-handling mode here, so avoid bailing with verifyNotNull + logger.warn("Failed to locate tab pane."); + } + + Dialogs.showError(stage, BundleUtils.getLabel("message_error_gameStart")); + } + private void downloadAction() { downloadTask = new DownloadTask(packageManager, selectedVersion); @@ -297,8 +320,6 @@ public void update(final Path newLauncherDirectory, final Path newDownloadDirect logbackLogger.addAppender(logViewController); } - gameStarter = new GameStarter(); - packageItems = FXCollections.observableArrayList(); onSync(); @@ -316,7 +337,7 @@ public void update(final Path newLauncherDirectory, final Path newDownloadDirect initializeComboBoxSelection(); } - // To be called after database sync is done + /** To be called after database sync is done. */ private void onSync() { packageItems.clear(); packageManager.getPackages() @@ -457,7 +478,6 @@ private void close() { } catch (IOException e) { logger.warn("Could not store current launcher settings!"); } - gameStarter.dispose(); // TODO: Improve close request handling if (downloadTask != null) { diff --git a/src/main/java/org/terasology/launcher/gui/javafx/FxTimer.java b/src/main/java/org/terasology/launcher/gui/javafx/FxTimer.java new file mode 100644 index 000000000..fc5b29959 --- /dev/null +++ b/src/main/java/org/terasology/launcher/gui/javafx/FxTimer.java @@ -0,0 +1,83 @@ +/* + * Copyright 2019 MovingBlocks + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.terasology.launcher.gui.javafx; + +import javafx.animation.KeyFrame; +import javafx.animation.Timeline; +import javafx.util.Duration; + +/** + * Provides factory methods for timers that are manipulated from and execute their action on the JavaFX application + * thread. + *

+ * Derived from ReactFX. + * + * @see ReactFX + */ +public final class FxTimer { + private final Duration actionTime; + private final Timeline timeline; + private final Runnable action; + + private long seq; + + + private FxTimer(java.time.Duration actionTime, java.time.Duration period, Runnable action, int cycles) { + this.actionTime = Duration.millis(actionTime.toMillis()); + this.timeline = new Timeline(); + this.action = action; + + timeline.getKeyFrames().add(new KeyFrame(this.actionTime)); // used as placeholder + if (period != actionTime) { + timeline.getKeyFrames().add(new KeyFrame(Duration.millis(period.toMillis()))); + } + + timeline.setCycleCount(cycles); + } + + /** + * Prepares a (stopped) timer that lasts for {@code delay} and whose action runs when timer ends. + */ + public static FxTimer create(java.time.Duration delay, Runnable action) { + return new FxTimer(delay, delay, action, 1); + } + + /** + * Equivalent to {@code create(delay, action).restart()}. + */ + public static FxTimer runLater(java.time.Duration delay, Runnable action) { + FxTimer timer = create(delay, action); + timer.restart(); + return timer; + } + + public void restart() { + stop(); + long expected = seq; + timeline.getKeyFrames().set(0, new KeyFrame(actionTime, ae -> { + if (seq == expected) { + action.run(); + } + })); + timeline.play(); + } + + public void stop() { + timeline.stop(); + ++seq; + } +} diff --git a/src/main/java/org/terasology/launcher/gui/javafx/LogViewController.java b/src/main/java/org/terasology/launcher/gui/javafx/LogViewController.java index defe2ad3e..4aa6388b0 100644 --- a/src/main/java/org/terasology/launcher/gui/javafx/LogViewController.java +++ b/src/main/java/org/terasology/launcher/gui/javafx/LogViewController.java @@ -16,6 +16,8 @@ package org.terasology.launcher.gui.javafx; +import ch.qos.logback.classic.pattern.RootCauseFirstThrowableProxyConverter; +import ch.qos.logback.classic.pattern.ThrowableHandlingConverter; import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.core.AppenderBase; import javafx.concurrent.ScheduledService; @@ -35,12 +37,14 @@ public class LogViewController extends AppenderBase { private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss.SSS"); private final StringBuffer buffer; + private final ThrowableHandlingConverter throwableConverter; @FXML private TextArea logArea; public LogViewController() { buffer = new StringBuffer(); + throwableConverter = new RootCauseFirstThrowableProxyConverter(); ScheduledService schedule = new ScheduledService() { @Override @@ -81,5 +85,11 @@ protected void append(ILoggingEvent loggingEvent) { buffer.append(String.format("%s | %-5s | ", DATE_FORMATTER.format(timestamp), loggingEvent.getLevel())); buffer.append(message); buffer.append("\n"); + + var error = loggingEvent.getThrowableProxy(); + if (error != null) { + var s = throwableConverter.convert(loggingEvent); + buffer.append(s); + } } } diff --git a/src/main/resources/org/terasology/launcher/views/application.fxml b/src/main/resources/org/terasology/launcher/views/application.fxml index 4738adb42..30a0b0c35 100644 --- a/src/main/resources/org/terasology/launcher/views/application.fxml +++ b/src/main/resources/org/terasology/launcher/views/application.fxml @@ -75,7 +75,7 @@ - + diff --git a/src/test/java/org/terasology/launcher/Matchers.java b/src/test/java/org/terasology/launcher/Matchers.java new file mode 100644 index 000000000..cedc0721b --- /dev/null +++ b/src/test/java/org/terasology/launcher/Matchers.java @@ -0,0 +1,53 @@ +/* + * Copyright 2020 MovingBlocks + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.terasology.launcher; + +import org.hamcrest.Matcher; +import org.hamcrest.core.AllOf; +import org.hamcrest.core.IsIterableContaining; + +import java.util.Collection; +import java.util.stream.Collectors; + +public final class Matchers { + + private Matchers() { } + + /** + * Creates a matcher for {@link Iterable}s that matches when consecutive passes over the + * examined {@link Iterable} yield at least one item that is equal to the corresponding + * item from the specified items. Whilst matching, each traversal of the + * examined {@link Iterable} will stop as soon as a matching item is found. + *

+ * For example: + *

+ * {@code + * assertThat(Arrays.asList("foo", "bar", "baz"), hasItemsFrom(List.of("baz", "foo"))) + * } + * + * @param items + * the items to compare against the items provided by the examined {@link Iterable} + */ + public static Matcher> hasItemsFrom(Collection items) { + /* org.hamcrest.Matchers.hasItems(T...) takes variable arguments, so if + * we want to match against a list, we reimplement it. + */ + return new AllOf<>(items.stream().map( + IsIterableContaining::hasItem + ).collect(Collectors.toUnmodifiableList())); + } +} diff --git a/src/test/java/org/terasology/launcher/SlowTest.java b/src/test/java/org/terasology/launcher/SlowTest.java new file mode 100644 index 000000000..0ad816b03 --- /dev/null +++ b/src/test/java/org/terasology/launcher/SlowTest.java @@ -0,0 +1,41 @@ +/* + * Copyright 2020 MovingBlocks + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.terasology.launcher; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.ANNOTATION_TYPE; +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + + +/** + * A test that is slower than normal. + * + * e.g. it uses external processes or other resources that take a little time to set up and + * tear down. + */ +@Target({ METHOD, ANNOTATION_TYPE }) +@Retention(RUNTIME) +@Test +@Tag("slow") +public @interface SlowTest { +} diff --git a/src/test/java/org/terasology/launcher/StringIteratorInputStream.java b/src/test/java/org/terasology/launcher/StringIteratorInputStream.java new file mode 100644 index 000000000..54c8c50a4 --- /dev/null +++ b/src/test/java/org/terasology/launcher/StringIteratorInputStream.java @@ -0,0 +1,128 @@ +/* + * Copyright 2020 MovingBlocks + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.terasology.launcher; + +import com.google.common.primitives.Ints; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.util.Iterator; +import java.util.NoSuchElementException; + +/** + * An {@code InputStream} fed by an {@code Iterator}. + *

+ * Testing something that consumes an {@link InputStream} {@linkplain BufferedReader#lines() line-by-line}? Your test + * fixture has a list of strings to use as inputs; how hard could it be to get an InputStream that will pass them one + * at a time? + *

+ * About this hard, it turns out. + *

+ * This was not written with high-volume or high-throughput use cases in mind. It will handle a few + * lines (or even a few kilobytes) in your test suite just fine. I do not recommend you run your server + * traffic through it. + */ +public class StringIteratorInputStream extends InputStream { + static final int COOLDOWN_LENGTH = 1; + + int cooldown; + private final Iterator source; + private byte[] currentLine; + private int byteIndex; + + public StringIteratorInputStream(Iterator source) { + this.source = source; + resetCooldown(); + } + + @Override + public int available() { + if (currentLine == null) { + // Playing hard-to-get with StreamDecoder.read. If it finishes a read + // and still has room in its buffer, it'll check if we're ready again + // right away. When we say we still aren't ready yet, it backs off. + if (cooldown > 0) { + cooldown -= 1; + return 0; + } + if (!loadNextLine()) { + // there's no more to be had. for real this time! + return 0; + } + } + return availableInCurrentLine(); + } + + @Override + public int read() { + if (currentLine == null) { + var gotNext = loadNextLine(); + if (!gotNext) { + return -1; + } + } + var c = currentLine[byteIndex]; + byteIndex++; + if (byteIndex >= currentLine.length) { + currentLine = null; + resetCooldown(); + } + return c; + } + + @Override + public int read(final byte[] b, final int off, final int len) throws IOException { + if (len == 0) { + return 0; + } + // Even if available() says we're empty, our superclass wants us to try + // to come up with at least one byte, blocking if necessary. Otherwise + // StreamDecoder.readBytes says "Underlying input stream returned zero bytes" + // and implodes. + @SuppressWarnings("UnstableApiUsage") var availableLength = + Ints.constrainToRange(availableInCurrentLine(), 1, len); + return super.read(b, off, availableLength); + } + + protected int availableInCurrentLine() { + if (currentLine == null) { + return 0; + } else { + return currentLine.length - byteIndex; + } + } + + private void resetCooldown() { + cooldown = COOLDOWN_LENGTH; + } + + /** + * @return true when it succeeds in getting the next line, false when the input source has no more + */ + private boolean loadNextLine() { + try { + final String nextString = source.next(); + currentLine = nextString.getBytes(); + } catch (NoSuchElementException e) { + return false; + } finally { + byteIndex = 0; + } + return true; + } +} diff --git a/src/test/java/org/terasology/launcher/game/MockProcesses.java b/src/test/java/org/terasology/launcher/game/MockProcesses.java new file mode 100644 index 000000000..e5e84a235 --- /dev/null +++ b/src/test/java/org/terasology/launcher/game/MockProcesses.java @@ -0,0 +1,112 @@ +/* + * Copyright 2020 MovingBlocks + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.terasology.launcher.game; + +import org.apache.commons.io.input.NullInputStream; +import org.terasology.launcher.StringIteratorInputStream; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Iterator; +import java.util.Random; +import java.util.concurrent.Callable; + +public final class MockProcesses { + static final Callable EXCEPTION_THROWING_START = () -> { + throw new OurIOException("GRUMPY \uD83D\uDC7F"); + }; + + private MockProcesses() { + } + + public static class HappyGameProcess extends Process { + + private final InputStream inputStream; + private long pid; + + HappyGameProcess() { + inputStream = new NullInputStream(0); + } + + HappyGameProcess(String processOutput) { + inputStream = new ByteArrayInputStream(processOutput.getBytes()); + } + + @Override + public OutputStream getOutputStream() { + throw new UnsupportedOperationException("Stub."); + } + + @Override + public InputStream getInputStream() { + return inputStream; + } + + @Override + public InputStream getErrorStream() { + throw new UnsupportedOperationException("Stub; implement if we stop merging stdout and error streams."); + } + + @Override + public int waitFor() { + return exitValue(); + } + + @Override + public long pid() { + if (this.pid == 0) { + this.pid = new Random().nextLong(); + } + return this.pid; + } + + @Override + public int exitValue() { + return 0; + } + + @Override + public void destroy() { + throw new UnsupportedOperationException(); + } + } + + static class OneLineAtATimeProcess extends HappyGameProcess { + private final Iterator lines; + + OneLineAtATimeProcess(Iterator lines) { + this.lines = lines; + } + + @Override + public InputStream getInputStream() { + // πŸ€” If RunGameTask had a way to do + // Iterator lines = adapt(process) + // and we could provide a different adapter for our mock process than a system + // process, we could probably do without StringIteratorInputStream. ? + return new StringIteratorInputStream(lines); + } + } + + static class OurIOException extends IOException { + OurIOException(final String grumpy) { + super(grumpy); + } + } +} diff --git a/src/test/java/org/terasology/launcher/game/TestGameRunner.java b/src/test/java/org/terasology/launcher/game/TestGameRunner.java deleted file mode 100644 index 42c4a6730..000000000 --- a/src/test/java/org/terasology/launcher/game/TestGameRunner.java +++ /dev/null @@ -1,186 +0,0 @@ -/* - * Copyright 2017 MovingBlocks - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.terasology.launcher.game; - -import org.hamcrest.Matchers; -import org.junit.Before; -import org.junit.Test; // junit because Spf4jTestLogRunner is not a Jupiter Extension yet -import org.junit.runner.RunWith; -import org.spf4j.log.Level; -import org.spf4j.test.log.LogAssert; -import org.spf4j.test.log.TestLoggers; -import org.spf4j.test.log.annotations.CollectLogs; -import org.spf4j.test.log.annotations.ExpectLog; -import org.spf4j.test.log.annotations.PrintLogs; -import org.spf4j.test.log.junit4.Spf4jTestLogJUnitRunner; -import org.spf4j.test.matchers.LogMatchers; - -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.io.InputStream; - -import static org.hamcrest.Matchers.allOf; -import static org.hamcrest.Matchers.containsString; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.anyInt; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.when; - - -// /!\ WARNING: If you take off this Spf4jTestLogJUnitRunner -// (e.g. to run as junit5 instead of vintage-junit4), it will -// initially look like tests pass but the annotations won't work. -// Make sure you can still get the assertions to *fail* when changed, -// and that failures still show all the logs specified in CollectLogs -// annotations. -// -// See: https://github.com/zolyfarkas/spf4j/issues/50 -@RunWith(Spf4jTestLogJUnitRunner.class) -public class TestGameRunner { - - /** - * Passed around to GameRunners to simulate a running game process. - */ - private Process gameProcess; - private TestLoggers testLog; - - @Before - public void setup() throws Exception { - testLog = TestLoggers.sys(); - - // Create fake game process to give to GameRunner - gameProcess = mock(Process.class); - when(gameProcess.getInputStream()).thenReturn(new ByteArrayInputStream(new byte[0])); - } - - @Test - // CollectLogs(TRACE) isn't necessary for this test's assertions. - // Logs are shown on test failure, but the default doesn't include TRACE. - // Because TRACE logs are relevant to this test we use this to include them in the output. - @CollectLogs(minLevel = Level.TRACE) - public void testGameOutput() throws Exception { - // Simulate game process outputting TEST_STRING - String testString = "LineOne\nLineTwo"; - when(gameProcess.getInputStream()).thenReturn(new ByteArrayInputStream(testString.getBytes())); - - // Can have checks be relatively loose: - // With logs in any category, at TRACE or above, - LogAssert broadExpectation = testLog.expect("", Level.TRACE, - // expect a message that contains "LineOne" - LogMatchers.hasMatchingMessage(containsString("LineOne")), - // also a message that contains "LineTwo" - LogMatchers.hasMatchingMessage(containsString("LineTwo")) - // or to match the message exactly, - // LogMatchers.hasMessage("LineTwo") - ); - - // You can also get very specific about how the log message is made. - // We'll define a matcher to look specifically at records whose - // format string begin with "Game output" - var hasGameOutputFormat = LogMatchers.hasFormatWithPattern("^Game output.*"); - - // These expectations are restricted specifically to logs sent to a logger named like this class. - LogAssert detailedExpectation = testLog.expect(GameRunner.class.getName(), Level.TRACE, - // Check its format against our matcher, and also that it has an argument that is exactly "LineOne" - allOf(hasGameOutputFormat, LogMatchers.hasArguments("LineOne")), - // ...and also for line two. - allOf(hasGameOutputFormat, LogMatchers.hasArguments("LineTwo")) - ); - - // Log expectations must be set up *before* the logs are generated. - // Now we can run the code under test: - GameRunner gameRunner = new GameRunner(gameProcess); - gameRunner.run(); - - // And check to see if the assertions held. - broadExpectation.assertObservation(); - detailedExpectation.assertObservation(); - - // be sure to run those assertions! If you set expectations but never check them, - // this test runner does *not* notice and warn you. - } - - @Test - // There's an annotation you can use for simple cases. It auto-asserts when the method finishes. - @ExpectLog(level = Level.DEBUG, messageRegexp = "Game closed with the exit value '0'.") - public void testGameExitSuccessful() { - GameRunner gameRunner = new GameRunner(gameProcess); - gameRunner.run(); - } - - @Test - // The log-printer's default setting is to display all logged errors. - // But we know the code under test will be logging an error and that - // doesn't mean there's an error in the scenario. We can override the - // settings for this logger to not print error logs from that class. - // - // The error logs will still be collected and displayed if the test fails. - @PrintLogs(category = "org.terasology.launcher.game.GameRunner", minLevel = Level.OFF, ideMinLevel = Level.OFF, greedy = true) - public void testGameEarlyInterrupt() throws Exception { - GameRunner gameRunner = spy(new GameRunner(gameProcess)); - // Simulate early game thread interruption - when(gameRunner.isInterrupted()).thenReturn(true); - - var interruptionLogged = testLog.expect("", Level.DEBUG, LogMatchers.hasMessage("Game thread interrupted.")); - - // Run game process - gameRunner.run(); - - interruptionLogged.assertObservation(); - } - - @Test - @PrintLogs(category = "org.terasology.launcher.game.GameRunner", minLevel = Level.OFF, ideMinLevel = Level.OFF, greedy = true) - public void testGameLateInterrupt() throws Exception { - // Simulate late game thread interruption (while game is running) - when(gameProcess.waitFor()).thenThrow(new InterruptedException()); - - var loggedException = testLog.expect("", Level.ERROR, Matchers.allOf( - LogMatchers.hasMatchingExtraThrowable(Matchers.instanceOf(InterruptedException.class)), - LogMatchers.hasMessage("The game thread was interrupted!") - )); - - // Run game process - GameRunner gameRunner = new GameRunner(gameProcess); - gameRunner.run(); - - // Make sure GameRunner logs an error with an InterruptedException - loggedException.assertObservation(); - } - - @Test - @PrintLogs(category = "org.terasology.launcher.game.GameRunner", minLevel = Level.OFF, ideMinLevel = Level.OFF, greedy = true) - public void testGameOutputError() throws Exception { - // Simulate an invalid output stream (that throws an IOException when read from because it's unhappy with its life) - InputStream badStream = mock(InputStream.class); - when(badStream.read(any(byte[].class), anyInt(), anyInt())).thenThrow(new IOException("Unhappy with life!")); - - when(gameProcess.getInputStream()).thenReturn(badStream); - - var loggedException = testLog.expect("", Level.ERROR, Matchers.allOf( - LogMatchers.hasMatchingExtraThrowable(Matchers.instanceOf(IOException.class)), - LogMatchers.hasMessage("Could not read game output!") - )); - - // Run game process - GameRunner gameRunner = new GameRunner(gameProcess); - gameRunner.run(); - - // Make sure GameRunner logs an error with an IOException - loggedException.assertObservation(); - } -} diff --git a/src/test/java/org/terasology/launcher/game/TestGameStarter.java b/src/test/java/org/terasology/launcher/game/TestGameStarter.java new file mode 100644 index 000000000..77fc138e4 --- /dev/null +++ b/src/test/java/org/terasology/launcher/game/TestGameStarter.java @@ -0,0 +1,91 @@ +/* + * Copyright 2020 MovingBlocks + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.terasology.launcher.game; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.event.Level; +import org.terasology.launcher.util.JavaHeapSize; + +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Path; +import java.util.List; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasItem; +import static org.junit.jupiter.api.Assertions.*; +import static org.terasology.launcher.Matchers.hasItemsFrom; + +public class TestGameStarter { + static final String JAVA_ARG_1 = "-client"; + static final String JAVA_ARG_2 = "--enable-preview"; + static final String GAME_ARG_1 = "--no-splash"; + static final String GAME_DIR = "game"; + static final String GAME_DATA_DIR = "game_data"; + static final JavaHeapSize HEAP_MIN = JavaHeapSize.NOT_USED; + static final JavaHeapSize HEAP_MAX = JavaHeapSize.GB_4; + static final Level LOG_LEVEL = Level.INFO; + + private final FileSystem fs = FileSystems.getDefault(); + private Path gamePath; + private List javaParams; + private List gameParams; + private Path gameDataPath; + + @BeforeEach + public void setup() { + gamePath = fs.getPath(GAME_DIR); + gameDataPath = fs.getPath(GAME_DATA_DIR); + javaParams = List.of(JAVA_ARG_1, JAVA_ARG_2); + gameParams = List.of(GAME_ARG_1); + } + + @Test + public void testConstruction() { + GameStarter starter = newStarter(); + assertNotNull(starter); + } + + private GameStarter newStarter() { + return new GameStarter(gamePath, gameDataPath, HEAP_MIN, HEAP_MAX, javaParams, gameParams, LOG_LEVEL); + } + + @Test + public void testJre() { + GameStarter task = newStarter(); + // This is the sort of test where the code under test and the expectation are just copies + // of the same source. But since there's a plan to separate the launcher runtime from the + // game runtime, the runtime location seemed like a good thing to specify in its own test. + assertTrue(task.getRuntimePath().startsWith(Path.of(System.getProperty("java.home")))); + } + + @Test + public void testBuildProcess() { + GameStarter starter = newStarter(); + ProcessBuilder processBuilder = starter.processBuilder; + final Path gameJar = gamePath.resolve(Path.of("libs", "Terasology.jar")); + + assertNotNull(processBuilder.directory()); + assertEquals(gamePath, processBuilder.directory().toPath()); + assertThat(processBuilder.command(), hasItem(gameJar.toString())); + assertThat(processBuilder.command(), hasItemsFrom(gameParams)); + assertThat(processBuilder.command(), hasItemsFrom(javaParams)); + // TODO: heap min, heap max, log level + // could parameterize this test for the things that are optional? + // heap min, heap max, log level, gameParams and javaParams are all optional. + } +} diff --git a/src/test/java/org/terasology/launcher/game/TestRunGameTask.java b/src/test/java/org/terasology/launcher/game/TestRunGameTask.java new file mode 100644 index 000000000..2143d4f4b --- /dev/null +++ b/src/test/java/org/terasology/launcher/game/TestRunGameTask.java @@ -0,0 +1,356 @@ +// Copyright 2020 The Terasology Foundation +// SPDX-License-Identifier: Apache-2.0 + +package org.terasology.launcher.game; + +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import javafx.concurrent.WorkerStateEvent; +import javafx.util.Pair; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.extension.ExtendWith; +import org.spf4j.log.Level; +import org.spf4j.test.log.LogAssert; +import org.spf4j.test.log.TestLoggers; +import org.spf4j.test.matchers.LogMatchers; +import org.terasology.launcher.SlowTest; +import org.testfx.framework.junit5.ApplicationExtension; + +import java.io.IOException; +import java.util.Iterator; +import java.util.List; +import java.util.Queue; +import java.util.concurrent.Callable; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.anyOf; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasToString; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.not; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +@Timeout(5) +@ExtendWith(ApplicationExtension.class) +public class TestRunGameTask { + + static final int EXIT_CODE_OK = 0; + static final int EXIT_CODE_ERROR = 1; + static final int EXIT_CODE_SIGKILL = 0b10000000 + 9; // SIGKILL = 9 + static final int EXIT_CODE_SIGTERM = 0b10000000 + 15; // SIGTERM = 15 + + private ExecutorService executor; + + @SuppressWarnings("SameParameterValue") + static ExecutorService singleThreadExecutor(String nameFormat) { + var builder = new ThreadFactoryBuilder().setNameFormat(nameFormat); + return Executors.newSingleThreadExecutor(builder.build()); + } + + @BeforeEach + void setUp() { + // Would it be plausible to do a @BeforeAll thing that provides a thread pool we don't have + // to tear down for each test? What kind of assertions would we have to make between tests + // to ensure it's in a clean state? + executor = singleThreadExecutor("gameTask-%s"); + } + + @AfterEach + void tearDown() throws InterruptedException { + assertThat(executor.shutdownNow(), empty()); + executor.awaitTermination(100, TimeUnit.MILLISECONDS); + assertTrue(executor.isTerminated()); + } + + @Test + public void testGameOutput() throws InterruptedException, RunGameTask.RunGameError { + String[] gameOutputLines = {"LineOne", "LineTwo"}; + Process gameProcess = new MockProcesses.HappyGameProcess(String.join("\n", gameOutputLines)); + + var hasGameOutputFormat = LogMatchers.hasFormatWithPattern("^Game output.*"); + + LogAssert detailedExpectation = TestLoggers.sys().expect( + RunGameTask.class.getName(), Level.INFO, + allOf(hasGameOutputFormat, LogMatchers.hasArguments(gameOutputLines[0])), + allOf(hasGameOutputFormat, LogMatchers.hasArguments(gameOutputLines[1])) + ); + + new NonTimingGameTask(null).monitorProcess(gameProcess); + + detailedExpectation.assertObservation(); + } + + @SlowTest + @DisabledOnOs(OS.WINDOWS) + public void testGameExitSuccessful() throws InterruptedException, ExecutionException { + var gameTask = new NonTimingGameTask(UnixProcesses.COMPLETES_SUCCESSFULLY); + + // we can use TestLogger expectations without Slf4jTestRunner, we just can't + // depend on their annotations. I think. + var hasExitMessage = TestLoggers.sys().expect( + RunGameTask.class.getName(), Level.DEBUG, + allOf( + LogMatchers.hasFormatWithPattern("Game closed with the exit value.*"), + LogMatchers.hasArguments(EXIT_CODE_OK) + ) + ); + + executor.submit(gameTask); + gameTask.get(); + + hasExitMessage.assertObservation(100, TimeUnit.MILLISECONDS); + } + + @Test + @DisabledOnOs(OS.WINDOWS) + public void testGameExitError() throws InterruptedException { + var gameTask = new RunGameTask(UnixProcesses.COMPLETES_WITH_ERROR); + + var hasExitMessage = TestLoggers.sys().expect( + RunGameTask.class.getName(), Level.DEBUG, + allOf( + LogMatchers.hasFormatWithPattern("Game closed with the exit value.*"), + LogMatchers.hasArguments(EXIT_CODE_ERROR) + ) + ); + + executor.submit(gameTask); + var thrown = assertThrows(ExecutionException.class, gameTask::get); + Throwable exc = thrown.getCause(); + assertThat(exc, instanceOf(RunGameTask.GameExitError.class)); + assertEquals(EXIT_CODE_ERROR, ((RunGameTask.GameExitError) exc).exitValue); + + hasExitMessage.assertObservation(100, TimeUnit.MILLISECONDS); + } + + @Test + public void testBadStarter() { + var gameTask = new RunGameTask(MockProcesses.EXCEPTION_THROWING_START); + + executor.submit(gameTask); + var thrown = assertThrows(ExecutionException.class, gameTask::get); + Throwable exc = thrown.getCause(); + assertThat(exc, instanceOf(RunGameTask.GameStartError.class)); + assertThat(exc.getCause(), instanceOf(MockProcesses.OurIOException.class)); + } + + @SlowTest + public void testExeNotFound() { + // not disabled-on-Windows because all platforms should be capable of failing + var gameTask = new RunGameTask(UnixProcesses.NO_SUCH_COMMAND); + + executor.submit(gameTask); + var thrown = assertThrows(ExecutionException.class, gameTask::get); + Throwable exc = thrown.getCause(); + assertThat(exc, instanceOf(RunGameTask.GameStartError.class)); + var cause = exc.getCause(); + assertThat(cause, instanceOf(IOException.class)); + assertThat(cause, anyOf( + hasToString(containsString("No such file")), + hasToString(containsString("The system cannot find the file specified")) + )); + } + + @SlowTest + @DisabledOnOs(OS.WINDOWS) + public void testTerminatedProcess() { + var gameTask = new RunGameTask(new UnixProcesses.SelfDestructingProcess(5)); + + executor.submit(gameTask); + var thrown = assertThrows(ExecutionException.class, gameTask::get); + Throwable exc = thrown.getCause(); + assertThat(exc, instanceOf(RunGameTask.GameExitError.class)); + final int exitValue = ((RunGameTask.GameExitError) exc).exitValue; + // It is redundant to test both that a value is one thing and + // also is not a different thing, but it'd be informative test output if + // it fails with the other signal. + assertThat(exitValue, allOf(equalTo(EXIT_CODE_SIGTERM), not(EXIT_CODE_SIGKILL))); + } + + + @Test + public void testSuccessEvent() throws Exception { + // Matches {@link RunGameTask.START_MATCH} + final String confirmedStart = "terasology.engine.TerasologyEngine - Initialization completed"; + + final List mockOutputLines = List.of( + "some babble\n", + confirmedStart + "\n", + "more babble\n", + "have a nice day etc\n" + ); + + // A record of observed events (thread-safe). + final Queue> actualHistory = new ConcurrentLinkedQueue<>(); + + final List> expectedHistory = List.of( + Happenings.PROCESS_OUTPUT_LINE.val(), + Happenings.PROCESS_OUTPUT_LINE.val(), + Happenings.TASK_VALUE_SET.val(true), // that line was the confirmedStart event! + Happenings.PROCESS_OUTPUT_LINE.val(), + Happenings.PROCESS_OUTPUT_LINE.val(), + Happenings.TASK_COMPLETED.val() + ); + + final Runnable handleLineSent = () -> actualHistory.add(Happenings.PROCESS_OUTPUT_LINE.val()); + + // This makes our "process," which streams out its lines and runs the callback after each. + final Process lineAtATimeProcess = new MockProcesses.OneLineAtATimeProcess( + spyingIterator(mockOutputLines, handleLineSent)); + + // RunGameTask, the code under test, finally appears. + final var gameTask = new RunGameTask(() -> lineAtATimeProcess); + + // Arrange to record when things happen. + gameTask.valueProperty().addListener( + (x, y, newValue) -> actualHistory.add(Happenings.TASK_VALUE_SET.val(newValue)) + ); + + gameTask.addEventHandler( + WorkerStateEvent.WORKER_STATE_SUCCEEDED, + (event) -> actualHistory.add(Happenings.TASK_COMPLETED.val()) + ); + + // Act! + executor.submit(gameTask); + var actualReturnValue = gameTask.get(); // task.get blocks until it has run to completion + + // Assert! + assertTrue(actualReturnValue); + + assertIterableEquals(expectedHistory, actualHistory); + } + + @Test + public void testFastExitDoesNotResultInSuccess() throws ExecutionException, InterruptedException { + final List mockOutputLines = List.of( + "this is a line from some process\n", + "oh, everything is over already, goodbye" + ); + + // A record of observed events (thread-safe). + final Queue> actualHistory = new ConcurrentLinkedQueue<>(); + + final List> expectedHistory = List.of( + Happenings.PROCESS_OUTPUT_LINE.val(), + Happenings.PROCESS_OUTPUT_LINE.val(), + Happenings.TASK_FAILED.val() + ); + + final Runnable handleLineSent = () -> actualHistory.add(Happenings.PROCESS_OUTPUT_LINE.val()); + + // This makes our "process," which streams out its lines and runs the callback after each. + final Process lineAtATimeProcess = new MockProcesses.OneLineAtATimeProcess( + spyingIterator(mockOutputLines, handleLineSent)); + + // RunGameTask, the code under test, finally appears. + final var gameTask = new RunGameTask(() -> lineAtATimeProcess); + + // Arrange to record when things happen. + gameTask.valueProperty().addListener( + (x, y, newValue) -> actualHistory.add(Happenings.TASK_VALUE_SET.val(newValue)) + ); + + gameTask.addEventHandler( + WorkerStateEvent.WORKER_STATE_SUCCEEDED, + (event) -> actualHistory.add(Happenings.TASK_COMPLETED.val()) + ); + gameTask.addEventHandler( + WorkerStateEvent.WORKER_STATE_FAILED, + (event) -> actualHistory.add(Happenings.TASK_FAILED.val()) + ); + + // Act! + executor.submit(gameTask); + var thrown = assertThrows(ExecutionException.class, gameTask::get); + assertThat(thrown.getCause(), instanceOf(RunGameTask.GameExitTooSoon.class)); + + assertIterableEquals(expectedHistory, actualHistory, renderColumns(actualHistory, expectedHistory)); + } + + public static Supplier renderColumns(Iterable actualIterable, Iterable expectedIterable) { + return () -> { + var outputs = new StringBuilder(256); + + var expectedIter = expectedIterable.iterator(); + var actualIter = actualIterable.iterator(); + var ended = "β–‘".repeat(20); + + outputs.append(String.format("%27s\t%s\n", "Expected", "Actual")); + do { + var expected = expectedIter.hasNext() ? expectedIter.next() : ended; + var actual = actualIter.hasNext() ? actualIter.next() : ended; + var compared = expected.equals(actual) ? " " : "❌"; + + outputs.append(String.format("%27s %s %s\n", expected, compared, actual)); + } while (expectedIter.hasNext() || actualIter.hasNext()); + + return outputs.toString(); + }; + } + + + /** + * An Iterator that runs the given callback every iteration. + * + * @param list to be iterated over + * @param onNext to be called each iteration + */ + public static Iterator spyingIterator(List list, Runnable onNext) { + return list.stream().takeWhile(string -> { + onNext.run(); + return true; + }).iterator(); + } + + /** Things that happen in RunGameTask that we want to make assertions about. */ + enum Happenings { + PROCESS_OUTPUT_LINE, + TASK_VALUE_SET, + TASK_COMPLETED, + TASK_FAILED; + + ValuedHappening val(T value) { + return new ValuedHappening<>(this, value); + } + + ValuedHappening val() { + return new ValuedHappening<>(this, null); + } + + static final class ValuedHappening extends Pair { + private ValuedHappening(final Happenings key, final T value) { + super(key, value); + } + } + } + + + static class NonTimingGameTask extends RunGameTask { + NonTimingGameTask(final Callable starter) { + super(starter); + } + + @Override + protected void startTimer() { + // no timers here. + } + } +} diff --git a/src/test/java/org/terasology/launcher/game/UnixProcesses.java b/src/test/java/org/terasology/launcher/game/UnixProcesses.java new file mode 100644 index 000000000..7322ed6a9 --- /dev/null +++ b/src/test/java/org/terasology/launcher/game/UnixProcesses.java @@ -0,0 +1,84 @@ +/* + * Copyright 2020 MovingBlocks + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.terasology.launcher.game; + +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +public final class UnixProcesses { + static final Callable COMPLETES_SUCCESSFULLY = runProcess("true"); + static final Callable COMPLETES_WITH_ERROR = runProcess("false"); + static final Callable NO_SUCH_COMMAND = runProcess(() -> { + // If you have a program with this name on your path while running these tests, + // you have incredible luck. + return "nope" + UUID.randomUUID(); + }); + + private UnixProcesses() { + } + + private static Callable runProcess(String... command) { + final ProcessBuilder processBuilder = new ProcessBuilder(command); + processBuilder.redirectErrorStream(true); + return processBuilder::start; + } + + private static Callable runProcess(Supplier command) { + return runProcess(command.get()); + } + + private static class SlowTicker implements Callable { + private final int seconds; + + SlowTicker(int seconds) { + this.seconds = seconds; + } + + @Override + public Process call() throws IOException { + var pb = new ProcessBuilder( + "/bin/bash", "-c", + String.format("for i in $( seq %s ) ; do echo $i ; sleep 1 ; done", seconds) + ); + var proc = pb.start(); + LoggerFactory.getLogger(SlowTicker.class).debug(" ⏲ Ticker PID {}", proc.pid()); + return proc; + } + } + + static class SelfDestructingProcess extends SlowTicker { + SelfDestructingProcess(final int seconds) { + super(seconds); + } + + @Override + public Process call() throws IOException { + var proc = super.call(); + new ScheduledThreadPoolExecutor(1).schedule( + // looks like destroy = SIGTERM, + // destroyForcibly = SIGKILL + proc::destroy, 100, TimeUnit.MILLISECONDS); + return proc; + } + } +} diff --git a/src/test/java/org/terasology/launcher/util/TestFileUtils.java b/src/test/java/org/terasology/launcher/util/TestFileUtils.java index eed59a5cb..b243c2fd3 100644 --- a/src/test/java/org/terasology/launcher/util/TestFileUtils.java +++ b/src/test/java/org/terasology/launcher/util/TestFileUtils.java @@ -1,23 +1,12 @@ -/* - * Copyright 2017 MovingBlocks - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ +// Copyright 2020 The Terasology Foundation +// SPDX-License-Identifier: Apache-2.0 package org.terasology.launcher.util; import org.hamcrest.Matchers; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; import org.junit.jupiter.api.io.TempDir; import org.spf4j.log.Level; import org.spf4j.test.log.TestLoggers; @@ -52,9 +41,11 @@ public class TestFileUtils { public Path tempFolder; @Test + @DisabledOnOs(OS.WINDOWS) public void testCannotCreateDirectory() { final Path directory = tempFolder.resolve(DIRECTORY_NAME); var tempFolderFile = tempFolder.toFile(); + // for some reason this fails on Windows... assert tempFolderFile.setWritable(false); try { @@ -78,6 +69,7 @@ public void testNotDirectory() throws IOException { } @Test + @DisabledOnOs(OS.WINDOWS) public void testNoPerms() throws IOException { var directory = tempFolder.resolve(DIRECTORY_NAME); Files.createDirectory(directory, PosixFilePermissions.asFileAttribute(Collections.emptySet()));