/** * Copyright (C) 2001-2017 by RapidMiner and the contributors * * Complete list of developers available at our web site: * * http://rapidminer.com * * This program is free software: you can redistribute it and/or modify it under the terms of the * GNU Affero General Public License as published by the Free Software Foundation, either version 3 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see http://www.gnu.org/licenses/. */ package com.rapidminer.tools; import java.io.BufferedReader; import java.io.File; import java.io.FileReader; import java.io.IOException; import java.io.InputStreamReader; import java.io.PrintStream; import java.net.InetAddress; import java.net.ServerSocket; import java.net.Socket; import java.net.SocketException; import java.nio.file.Files; import java.nio.file.attribute.PosixFilePermission; import java.util.Arrays; import java.util.EnumSet; import java.util.LinkedList; import java.util.List; import java.util.Random; import java.util.logging.Level; import java.util.logging.Logger; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.NodeList; import org.xml.sax.SAXException; import com.rapidminer.io.process.XMLTools; import com.rapidminer.tools.SystemInfoUtilities.OperatingSystem; /** * When started for the first time, listens on a given socket on localhost. If started for the * second time, contacts this socket and passes command line options to this socket. We use a lock * file mechanism to make sure only one server instance exists. * * The port number on which we listen is stored in a file in the users home directory, * .RapidMiner/rapidminer.lock. The lock file contains two lines: the port number of the first * instance and a random session id. It is readable only to the current user. * * In order to use this class, first try to contact another instance by calling * {@link #sendArgsToOtherInstanceIfUp(String...)}. If true is returned, commands were sent to and * processed by the other instance successfully and we can terminate. If false is returned, the * other instance is not running. In that case, call {@link #installListener(RemoteControlHandler)}. * Now, when another instance is started, callbacks are made to the {@link RemoteControlHandler} * passed. Precisely this is done when calling * {@link #defaultLaunchWithArguments(String[], RemoteControlHandler)}. * * Currently, the only supported message accepted by the server has this format: * * <args session-id="12345678"><arg>arg1</arg><arg>arg2</arg>...</args> * * When opening the socket, the server will respond with <hello/> and confirm success with <ok/>. * Error messages come as <error>message</error>. * * This class is deliberately not using any fancy stuff like RMI to make debugging easy. Just * connect to socket and type to debug. Also, no sophisticated protocol is necessary since this * class will only talk to itself, and never even to a server of another version. * * @author Simon Fischer * */ public enum LaunchListener { /** The singleton instance. */ INSTANCE; /** Callbacks will be made to this interface when another client contacts us. */ public static interface RemoteControlHandler { /** Callback method called when another client starts. */ boolean handleArguments(String[] args); } /** Returned to client in case the command could not be executed. */ private static final String ERROR_COMMAND_FAILED = "<error>command execution failed</error>"; /** Error message indicating that the transmitted command is unknown. */ private static final String ERROR_UNKNOWN_COMMAND = "<error>unknown command</error>"; /** Error message indicating that the session id is missing from the command sent by the client. */ private static final String ERROR_NO_SESSION_ID = "<error>no session id</error>"; /** * Error message indicating that the session id sent by the client does not match the one * created by the server. */ private static final String ERROR_WRONG_SESSION_ID = "<error>wrong session id</error>"; /** The other instance rejected execution of the command. */ private static final String ERROR_REJECTED = "<error>rejected</error>"; /** Confirms successful execution on the command on the remote instance. */ private static final String RESPONSE_OK = "<ok/>"; /** Sent by the other instance to confirm it is RapidMiner. */ private static final String HELLO_MESSAGE = "<hello/>"; /** XML-Attribute in the root element holding the session id. */ private static final String ATTRIBUTE_SESSION_ID = "session-id"; private static final Logger LOGGER = Logger.getLogger(LaunchListener.class.getName()); /** Commands received from the client will be sent to this handler. */ private RemoteControlHandler handler; /** Identifies the session that this instance started or that is used by the other instance. */ private long sessionId; /** * Lock file holding the port number and session id. Should be readable only by the current * user. */ private File getLockFile() { return FileSystemService.getUserConfigFile("rapidminer.lock"); } /** Returns the singleton instance */ public static LaunchListener getInstance() { return INSTANCE; } /** * Starts a server socket on a random port that sends commands that it reads to the given * handler. * * Also creates {@link #getLockFile()} with information on how to contact this socket. * */ private void installListener(final RemoteControlHandler handler) throws IOException { // port 0 = let system assign port // backlog 1 = we don't expect simultaneous requests final ServerSocket serverSocket = new ServerSocket(0, 1, InetAddress.getLoopbackAddress()); final int port = serverSocket.getLocalPort(); final File socketFile = getLockFile(); LOGGER.config("Listening for other instances on port " + port + ". Writing " + socketFile + "."); PrintStream socketOut = new PrintStream(socketFile); socketOut.println(String.valueOf(port)); sessionId = new Random().nextLong(); socketOut.println(String.valueOf(sessionId)); socketOut.close(); try { OperatingSystem os = SystemInfoUtilities.getOperatingSystem(); if (os != OperatingSystem.WINDOWS) { Files.setPosixFilePermissions(socketFile.toPath(), EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE)); } } catch (UnsupportedOperationException e) { // ignore } socketFile.deleteOnExit(); Runtime.getRuntime().addShutdownHook(new Thread() { @Override public void run() { LOGGER.config("Deleting " + socketFile); socketFile.delete(); try { serverSocket.close(); } catch (IOException e) { // silent - we're dying anyway } } }); Thread listenerThread = new Thread("Launch-Listener") { @Override public void run() { LaunchListener.this.handler = handler; while (true) { Socket client; try { client = serverSocket.accept(); // We don't spawn another thread here. // Assume no malicious client and communication is quick. readFromSecondClient(client); } catch (SocketException e) { // small log level because the JVMOptionBuilder check can trigger it LogService.getRoot().log( Level.FINE, I18N.getMessage(LogService.getRoot().getResourceBundle(), "com.rapidminer.tools.LaunchListener.accepting_socket_connection_error", e.getMessage())); } catch (IOException e) { LogService.getRoot().log( Level.WARNING, I18N.getMessage(LogService.getRoot().getResourceBundle(), "com.rapidminer.tools.LaunchListener.accepting_socket_connection_error", e.getMessage()), e); } } } }; listenerThread.setDaemon(true); listenerThread.start(); } /** Reads and processes a command from the second client. */ private void readFromSecondClient(Socket client) { try { LOGGER.config("Second client launched."); PrintStream out = new PrintStream(client.getOutputStream()); out.println(HELLO_MESSAGE); out.flush(); Document doc; try { doc = XMLTools.parse(client.getInputStream()); } catch (SAXException e) { LOGGER.log(Level.FINE, "Unknown command from other client: " + e.getMessage()); out.println(ERROR_UNKNOWN_COMMAND); return; } LOGGER.config("Read XML document from other client: "); String sessionIdStr = doc.getDocumentElement().getAttribute(ATTRIBUTE_SESSION_ID); if (sessionIdStr == null || sessionIdStr.isEmpty()) { out.println(ERROR_NO_SESSION_ID); LOGGER.warning("Missing session id in call from other client."); } else if (!sessionIdStr.equals(String.valueOf(sessionId))) { out.println(ERROR_WRONG_SESSION_ID); LOGGER.warning("Wrong session id in call from other client."); } else { final String command = doc.getDocumentElement().getTagName(); if ("args".equals(command)) { NodeList argsElems = doc.getDocumentElement().getElementsByTagName("arg"); List<String> args = new LinkedList<String>(); for (int i = 0; i < argsElems.getLength(); i++) { args.add(argsElems.item(i).getTextContent()); } if (handler != null) { LOGGER.config("Handling <args> command from other client."); try { if (handler.handleArguments(args.toArray(new String[args.size()]))) { out.println(RESPONSE_OK); out.flush(); } else { out.println(ERROR_REJECTED); } } catch (Exception e) { LOGGER.log(Level.WARNING, "Error executing remote control command: " + e, e); out.println(ERROR_COMMAND_FAILED); } } else { LOGGER.warning("Other client sent <args> command, but I don't have a handler installed."); out.println(ERROR_COMMAND_FAILED); } } else { out.println(ERROR_UNKNOWN_COMMAND); LOGGER.warning("Unknown command from second client: <" + command + ">."); } } client.close(); } catch (SocketException e) { LOGGER.log(Level.CONFIG, "Talking to client aborted. Assume launcher instance availability check."); } catch (IOException e) { LOGGER.log(Level.WARNING, "Failed to talk to client: " + e, e); } } /** Obtains a socket talking to another RapidMiner Studio instance. */ private Socket getOtherInstance() { File socketFile = getLockFile(); if (!socketFile.exists()) { LOGGER.config("Lock file " + socketFile + " does not exist. Assuming I am the first instance."); return null; } int port; try (BufferedReader in = new BufferedReader(new FileReader(socketFile))) { String portStr = in.readLine(); String sessionIdStr = in.readLine(); port = Integer.parseInt(portStr); sessionId = Long.parseLong(sessionIdStr); } catch (Exception e) { LOGGER.log(Level.WARNING, "Failed to read socket file '" + socketFile + "': " + e, e); return null; } LOGGER.config("Checking for running instance on port " + port + "."); try { return new Socket(InetAddress.getLoopbackAddress(), port); } catch (IOException e) { LOGGER.warning("Found lock file but no other instance running. Assuming unclean shutdown of previous launch."); return null; } } /** * Checks whether the buffer contains a {@link #HELLO_MESSAGE} to verify this is a RapidMiner * Studio instance. */ private boolean readHelloMessage(BufferedReader in) throws IOException { boolean isRM; String line = in.readLine(); if (HELLO_MESSAGE.equals(line)) { LOGGER.log(Level.INFO, "Found other RapidMiner instance."); isRM = true; } else { LOGGER.config("Read unknown string from other instance: " + line); isRM = false; } return isRM; } /** * Reads the lock file and attempts to contact the other instance. If successful, sends the * arguments and returns true. Returns false otherwise (i.e. if the lock file is missing, the * other instance does not respond in case of an unclean shutdown, or the other instance * responds with an unexpected message). */ private boolean sendArgsToOtherInstanceIfUp(String... args) { final Socket other = getOtherInstance(); if (other == null) { return false; } try (BufferedReader in = new BufferedReader(new InputStreamReader(other.getInputStream()))) { boolean isRM = readHelloMessage(in); if (!isRM) { LOGGER.warning("Found other instance listening, but does not look like a RapidMiner instance."); return false; } else { // Only send if we really have arguments. Otherwise just check whether other // instance // is up so we do not spawn a new one. if (args.length > 0) { LOGGER.config("Sending arguments to other RapidMiner instance: " + Arrays.toString(args)); Document doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument(); Element root = doc.createElement("args"); root.setAttribute(ATTRIBUTE_SESSION_ID, String.valueOf(sessionId)); doc.appendChild(root); for (String arg : args) { Element argElem = doc.createElement("arg"); argElem.setTextContent(arg); root.appendChild(argElem); } XMLTools.stream(doc, other.getOutputStream(), null); } other.getOutputStream().close(); return true; } } catch (IOException e) { LOGGER.log(Level.WARNING, "Failed to talk to other instance: " + e, e); return false; } catch (ParserConfigurationException e) { LOGGER.log(Level.WARNING, "Cannot create XML document: " + e, e); return false; } catch (XMLException e) { LOGGER.log(Level.WARNING, "Cannot create XML document: " + e, e); return false; } } /** * Sends the arguments to the other client, if up. * * @return true if other client is not up, so we must continue launching our APP. * */ public static boolean defaultLaunchWithArguments(String[] args, RemoteControlHandler handler) throws IOException { ParameterService.init(); if (!getInstance().sendArgsToOtherInstanceIfUp(args)) { getInstance().installListener(handler); return true; } else { return false; } } }