package net.sf.colossus.webserver; import java.io.BufferedReader; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.PrintWriter; import java.util.LinkedList; import java.util.List; import java.util.logging.Level; import java.util.logging.Logger; import net.sf.colossus.common.Options; import net.sf.colossus.server.INotifyWebServer; import net.sf.colossus.webcommon.GameInfo; import net.sf.colossus.webcommon.GameInfo.GameState; import net.sf.colossus.webcommon.IGameRunner; import net.sf.colossus.webcommon.IRunWebServer; import net.sf.colossus.webcommon.User; /** * This class runs (starts and supervises) a Game on the Game Server * (as opposed to: on the User's PC). * It finds and reserves a port for it, starts it in a separate process * and when the process terminates, join()s it and releases the port. * * If the game is run on a user's PC, the class RunGameInSameJVM will be * used. * * @author Clemens Katzer */ public class RunGameInOwnJVM extends Thread implements IGameRunner { private static final Logger LOGGER = Logger .getLogger(RunGameInOwnJVM.class.getName()); private int hostingPort; private String hostingHost; private final IRunWebServer server; private final WebServerOptions options; private final GameInfo gi; private final String gameId; private String workFilesBaseDir; private String statisticsBaseDir; private String template; private String javaCommand; private String colossusJar; private File flagFile; private File suspendedFlagfile; private boolean alreadyStarted; private String reasonStartFailed; public RunGameInOwnJVM(IRunWebServer server, WebServerOptions options, GameInfo gi) { this.server = server; this.options = options; this.gi = gi; this.gameId = gi.getGameId(); this.alreadyStarted = false; gi.setGameRunner(this); } public boolean makeRunningGame() { workFilesBaseDir = options .getStringOption(WebServerConstants.optWorkFilesBaseDir); statisticsBaseDir = options .getStringOption(WebServerConstants.optStatisticsBaseDir); template = options .getStringOption(WebServerConstants.optLogPropTemplate); if (gi.wantsDetailedLogging()) { template += ".finest"; } javaCommand = options .getStringOption(WebServerConstants.optJavaCommand); colossusJar = options .getStringOption(WebServerConstants.optColossusJar); LOGGER.fine("Calling getFreePort for game " + gi.getGameId()); int port = server.getPortProvider().getFreePort(gi); if (port == -1) { reasonStartFailed = "No free ports!!"; return false; } hostingPort = port; hostingHost = gi.getHostingHost(); gi.setPort(port); LOGGER.fine("Using port " + port + " for game " + gi.getGameId()); this.setName("Game at port " + hostingPort); return true; } public int getHostingPort() { return hostingPort; } public String getHostingHost() { return hostingHost; } public String getReasonStartFailed() { return reasonStartFailed; } public boolean tryToStart() { synchronized (this) { if (alreadyStarted) { return false; } else { alreadyStarted = true; this.start(); return true; } } } @Override public void run() { runInOwnJVM(); } private void runInOwnJVM() { // Figure out the ??00-??99 part of the path: int intGameId = Integer.parseInt(gameId); int hundreds = intGameId - (intGameId % 100); Integer h00 = Integer.valueOf(hundreds); Integer h99 = Integer.valueOf(hundreds + 99); String dir0099Part = String.format("%04d-%04d", h00, h99); // E.g. game 6789 will be stored as workFilesBaseDir/6700-6799/6789 String dirPath = workFilesBaseDir + File.separator + dir0099Part; // force to be at least 4 digits, i.e. 0049 for game 49: String gameId4s = String.format("%04d", Integer.valueOf(intGameId)); File gameDir = new File(dirPath, gameId4s); gameDir.mkdirs(); String statDirPath = statisticsBaseDir + File.separator + dir0099Part; File statDir = new File(statDirPath, gameId4s); statDir.mkdirs(); LOGGER.finest("Creating dir " + statDir.toString()); File diceStatFile = new File(statDir, "dice-statistics.txt"); String flagFileName = "Game." + gameId + ".running.flag"; this.flagFile = new File(gameDir, flagFileName); if (flagFile.exists()) { flagFile.delete(); } this.suspendedFlagfile = new File(gameDir, flagFileName + ".suspended"); if (this.suspendedFlagfile.exists()) { suspendedFlagfile.delete(); } File logPropFile = new File(gameDir, "logging.properties"); File logPropTemplate = new File(template); boolean propFileOk = createLoggingPropertiesFromTemplate( logPropTemplate, logPropFile); // Stores data from GameInfo into an options object and saves // the options to file on disk in the special game directory. createServerCfgFile(gameDir, diceStatFile); Runtime rt = Runtime.getRuntime(); String loggingFileArg = propFileOk ? " -Djava.util.logging.config.file=" + logPropFile : ""; String randomDotOrgArg = ""; String prop; prop = System.getProperty("net.sf.colossus.randomDotOrgDirectory"); if (prop != null && !prop.equals("")) { randomDotOrgArg = " -Dnet.sf.colossus.randomDotOrgDirectory=" + prop; } String loadOptionString = ""; String loadFilename = this.gi.getResumeFromFilename(); if (loadFilename != null) { loadOptionString = " --latest"; } String command = javaCommand + loggingFileArg + randomDotOrgArg + " -Duser.home=" + gameDir + " -jar " + colossusJar + " -p " + hostingPort + " -g --flagfile " + flagFileName + loadOptionString; try { Process p = rt.exec(command, null, gameDir); p.getOutputStream().close(); NullDumper ndout = new NullDumper(p, false, p.getInputStream(), gameId + "_OUT: ").start(); NullDumper nderr = new NullDumper(p, false, p.getErrorStream(), gameId + "_ERR: ").start(); superviseGameStartup(); waitForGameShutdown(p, ndout, nderr); } catch (Exception e) { LOGGER.log(Level.SEVERE, "Executing\n " + command + "\ndid throw exception", e); } } private boolean createServerCfgFile(File gameDir, File diceStatisticsFile) { boolean ok = true; String gameDirPath = gameDir.getPath(); Options gameOptions = new Options("server", gameDirPath + "/.colossus/", false); // No local player if run on Webserver... String localPlayerName = null; boolean noAIs = true; gi.storeToOptionsObject(gameOptions, localPlayerName, noAIs); gameOptions.setOption(Options.autoQuit, true); gameOptions.setOption(Options.keepAccepting, true); String statFileName = diceStatisticsFile.toString(); LOGGER.finest("Statisticsfilename: " + statFileName); gameOptions.setOption(Options.diceStatisticsFile, statFileName); gameOptions.saveOptions(); return ok; } private boolean createLoggingPropertiesFromTemplate(File logPropTemplate, File logPropFile) { boolean ok = true; String patternLine = "java.util.logging.FileHandler.pattern="; try { String line; BufferedReader in = new BufferedReader(new InputStreamReader( new FileInputStream(logPropTemplate))); PrintWriter out = new PrintWriter( new FileOutputStream(logPropFile)); while ((line = in.readLine()) != null) { if (line.startsWith(patternLine)) { // String dirSep = File.separator; // if there is no path, it seems it uses current working // directory. // When we put path there, we would have to hard-coded // replace the \ of a Windows directory to /'es, // because as it looks the java logger only accepts those, // and uses backslashes just as quote character... line = patternLine + "Colossus%g.log"; } out.println(line); } in.close(); out.close(); } catch (FileNotFoundException e) { ok = false; } catch (IOException e) { ok = false; } return ok; } private void superviseGameStartup() { // ACTIVATED LOGGER.log(Level.FINEST, "Seems starting game process went ok, sending gameStartsSoon " + "to enrolled players: " + gi.getPlayerListAsString()); server.tellEnrolledGameStartsSoon(gi); int timeout = 30; // seconds boolean up = waitUntilReadyToAcceptClients(timeout); if (up) { LOGGER.log(Level.FINEST, "Game is ready to accept clients - sending gameStartsNow."); // READY_TO_CONNECT int port = gi.getPort(); // if run on GameServer, null. // TODO: real remote host there, when runs on players PC String hostingHost = null; server.tellEnrolledGameStartsNow(gi, hostingHost, port); server.allTellGameInfo(gi); boolean ok = waitUntilGameStartedSuccessfully(30); if (ok) { // RUNNING LOGGER.log(Level.FINEST, "Game " + gameId + " started ok - fine!"); server.gameStarted(gi); } else { LOGGER.log(Level.WARNING, "Game " + gameId + ": " + reasonStartFailed); server.informAllEnrolledAbout(gi, reasonStartFailed); } } else { reasonStartFailed = "did not reach READY_TO_CONNECT state!"; LOGGER.log(Level.SEVERE, "Game " + gameId + " " + reasonStartFailed); } } private void waitForGameShutdown(Process p, NullDumper ndout, NullDumper nderr) { try { LOGGER.log(Level.FINEST, "Waiting for process of game " + gameId + " to reap it."); int exitCode = p.waitFor(); if (exitCode != 0) { LOGGER.log(Level.WARNING, "Non-zero exit code (" + exitCode + ") of process for game " + gameId); } else { LOGGER.log(Level.FINEST, "Exit code of process for game " + gameId + " is " + exitCode + " - ok!"); } } catch (Exception e) { String reason = "Exception " + e.getMessage() + " during waitForGameShutdown game " + gameId; LOGGER.log(Level.WARNING, reason, e); server.gameFailed(gi, reason); } finally { ndout.done(); nderr.done(); } if (this.suspendedFlagfile.exists()) { String message = "Game " + gameId + " was suspended."; LOGGER.log(Level.INFO, message); gi.setState(GameState.SUSPENDED); } else if (flagFile.exists() && reasonStartFailed != null) { String message = "Game " + gameId + " ended but flagfile " + flagFile.toString() + " does still exist? " + "Well, start failed, so it's not that surprising. Renaming it..."; LOGGER.log(Level.INFO, message); flagFile.renameTo(new File(flagFile.getParent(), "flagfile.startFailed")); } else if (flagFile.exists()) { LOGGER.log(Level.WARNING, "Game " + gameId + " ended but flagfile " + flagFile.toString() + " does still exist...? Renaming it..."); flagFile.renameTo(new File(flagFile.getParent(), "flagfile.away")); } else { LOGGER.log(Level.FINEST, "Game " + gameId + " ended and flagfile " + flagFile.toString() + " is gone. Fine!"); } LOGGER.info("Before unregister game " + gameId); server.unregisterGame(gi, hostingPort); } /* * Checks whether the game is already started "far enough", i.e. * that the serverSocket is ready to accept clients. * Game started with --webserver flag will create the flag file * after it created the socket. */ private boolean isSocketUp() { if (flagFile == null) { return false; } if (flagFile.exists()) { return true; } else { return false; } } /* Waits until socket is up, i.e. game is ready to accept clients. */ public boolean waitUntilReadyToAcceptClients(int timeout) { boolean up = false; for (int i = 0; !up && i < timeout; i++) { up = isSocketUp(); if (!up) { sleepFor(1000); } i++; } return up; } private String waitForLine(BufferedReader in, int checkInterval) { String line = null; try { line = in.readLine(); if (line == null) { sleepFor(checkInterval); } } catch (IOException e1) { LOGGER .log(Level.SEVERE, "during wait for line: IOException: ", e1); } catch (RuntimeException e2) { LOGGER.log(Level.SEVERE, "during wait for line: RuntimeException: ", e2); } catch (Exception e3) { LOGGER.log(Level.SEVERE, "during wait for line: Whatever Exception: ", e3); } return line; } private String getMissingPlayers(List<String> names) { StringBuilder missing = new StringBuilder(); for (User u : gi.getPlayers()) { String uName = u.getName(); if (!names.contains(uName)) { if (missing.length() > 0) { missing.append(", "); } missing.append(uName); } } return missing.toString(); } public String listAsString(List<String> names) { StringBuilder namesSB = new StringBuilder(); for (String oneName : names) { if (namesSB.length() > 0) { namesSB.append(", "); } namesSB.append(oneName); } return namesSB.toString(); } public boolean waitUntilGameStartedSuccessfully(int timeout) { reasonStartFailed = null; BufferedReader in = null; try { in = new BufferedReader(new InputStreamReader(new FileInputStream( flagFile))); } catch (FileNotFoundException ef) { LOGGER.log(Level.SEVERE, "while waiting until game started successfully: ", ef); } if (in == null) { reasonStartFailed = "could not open flagfile for reading!!"; return false; } int connected = 0; int checkInterval = 1000; // every second String line; List<String> names = new LinkedList<String>(); boolean done = false; for (int i = 0; !done && i < timeout;) { String name = null; line = waitForLine(in, checkInterval); LOGGER.info("GOT: " + line); if (line == null) { // Didn't get anything => readLine timeout hit i++; } else if (line.startsWith("Local client connected: ")) { name = line.substring(24); connected++; } else if (line.startsWith("Remote client connected: ")) { name = line.substring(25); connected++; } else if (line.startsWith(INotifyWebServer.ALL_CLIENTS_CONNECTED)) { done = true; } else if (line.startsWith(INotifyWebServer.GAME_STARTUP_COMPLETED)) { done = true; } // TODO: for now there is only one possible reason, handle better! else if (line.startsWith(INotifyWebServer.GAME_STARTUP_FAILED)) { reasonStartFailed = "Game start reported error!"; done = true; } if (connected >= gi.getPlayers().size()) { done = true; } if (name != null) { names.add(name); String message = "Player " + name + " connected to game " + gi.getGameId(); server.informAllEnrolledAbout(gi, message); } } if (reasonStartFailed == null && !done) { reasonStartFailed = "Start timed out, done not set? "; } if (reasonStartFailed != null) { String connectedPlayers = listAsString(names); String missingPlayers = getMissingPlayers(names); reasonStartFailed = reasonStartFailed + " Connected: " + connectedPlayers + "; not connected: " + missingPlayers; } try { in.close(); } catch (IOException e) { // ignore } return (reasonStartFailed == null); } private void sleepFor(long millis) { try { Thread.sleep(millis); } catch (InterruptedException e) { LOGGER.log(Level.FINEST, "sleepFor: InterruptException caught... ignoring it..."); } } /** * NullDumper is a dummy reader that just consumes all the output * produced by a Game's process - similar to /dev/null. That is needed * because we have to take care to read all what comes on the * Game's processes stdout and stderr, otherwise the game would block * at some point. * * If the boolean argument toNull to constructor is false, it will * send the produced output to the log instead. * * TODO rename to toLog instead. Should toLog be default nowadays that * there is not much output any more? * */ private static class NullDumper implements Runnable { Process process; boolean toNull; BufferedReader reader; String prefix; Thread thread; public NullDumper(Process p, boolean toNull, InputStream is, String prefix) { this.process = p; this.toNull = toNull; this.reader = new BufferedReader(new InputStreamReader(is)); this.prefix = prefix; } public NullDumper start() { thread = new Thread(this); thread.setDaemon(true); thread.setPriority(Thread.NORM_PRIORITY); thread.start(); return this; } public void run() { synchronized (this) { if (thread == null) { thread = Thread.currentThread(); } if (process == null) { return; } } String line; while (true) { synchronized (this) { if (process == null) { return; } } try { line = reader.readLine(); } catch (IOException e) { // ignore & end return; } if (line == null) { return; } if (!this.toNull) { LOGGER.log(Level.INFO, prefix + line); } } } public void done() { synchronized (this) { process = null; if (thread != null) { thread.interrupt(); } thread = null; } try { this.reader.close(); } catch (IOException e) { LOGGER.log(Level.WARNING, "Nulldumper reader.close got " + e); } } } // END Class NullDumper }