/* * Copyright 2008 Google Inc. * * 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 com.google.gwt.junit.remote; import com.google.gwt.junit.remote.BrowserManagerProcess.ProcessExitCb; import java.io.IOException; import java.rmi.RemoteException; import java.rmi.server.UnicastRemoteObject; import java.util.HashMap; import java.util.LinkedList; import java.util.Map; import java.util.Queue; import java.util.StringTokenizer; import java.util.Timer; import java.util.logging.Level; import java.util.logging.Logger; /** * Manages instances of a web browser as child processes. This class is * experimental and unsupported. An instance of this class can create browser * windows using one specific shell-level command. It performs process * management (baby sitting) on behalf of a remote client. This can be useful * for running a GWTTestCase on a browser that cannot be run on the native * platform. For example, a GWTTestCase test running on Linux could use a remote * call to a Windows machine to test with Internet Explorer. * * <p> * Calling {@link #main(String[])} can instantiate and register multiple * instances of this class at given RMI namespace locations. * </p> * * <p> * This system has been tested on Internet Explorer 6 & 7. Firefox does not work * in the general case; if an existing Firefox process is already running, new * processes simply delegate to the existing process and terminate, which breaks * the model. A shell script that sets MOZNOREMOTE=1 and cleans up * locks/sessions is needed. Safari on MacOS requires very special treatment * given Safari's poor command line support, but that is beyond the scope of * this documentation. * </p> * * <p> * TODO(scottb): We technically need a watchdog thread to slurp up stdout and * stderr from the child processes, or they might block. However, most browsers * never write to stdout and stderr, so this is low priority. (There is now a * thread that is spawned for each task to wait for an exit value - this might * be adapted for that purpose one day.) * </p> * * <p> * This class is not actually serializable as-is, because timer is not * serializable. * </p> * * see http://bugs.sun.com/bugdatabase/view_bug.do;:YfiG?bug_id=4062587 */ public class BrowserManagerServer extends UnicastRemoteObject implements BrowserManager { /** * Implementation notes: <code>processByToken</code> must be locked before * performing any state-changing operations. */ /** * Entry in the launchCommandQueue to use when tasks are serialized. */ private static class LaunchCommand { long keepAliveMsecs; int token; String url; LaunchCommand(int tokenIn) { this(tokenIn, null, 0); } LaunchCommand(int tokenIn, String urlIn, long keepAliveMsecsIn) { token = tokenIn; url = urlIn; keepAliveMsecs = keepAliveMsecsIn; } @Override public boolean equals(Object obj) { if (obj instanceof LaunchCommand && ((LaunchCommand) obj).token == token) { return true; } return false; } @Override public int hashCode() { return token; } } private static final Logger logger = Logger.getLogger(BrowserManagerServer.class.getName()); /** * Starts up and registers one or more browser servers. Command-line entry * point. */ public static void main(String[] args) throws Exception { // Startup logic has been delegated to BrowserManagerServerLauncher // class to facilitate use of the ToolBase class for // argument handling. BrowserManagerServerLauncher serverMain = new BrowserManagerServerLauncher(); if (serverMain.doProcessArgs(args)) { serverMain.run(); } } /** * Receives an event when a child process exits. */ private final ProcessExitCb childExitCallback = new ProcessExitCb() { /** * Called back from BrowserManagerProcess in a DIFFERENT THREAD than the * main thread. * * @param token token value of browser that exited. * @param exitValue exit status of the browser. */ public void childExited(int token, int exitValue) { synchronized (processByToken) { processByToken.remove(token); // Start up any commands that were delayed. launchDelayedCommand(); } } }; /** * The shell command to launch when a new browser is requested. */ private final String launchCmd; /** * A queue of delayed commands. This is used if the serialized option is * turned on. */ private Queue<LaunchCommand> launchCommandQueue = new LinkedList<LaunchCommand>(); /** * The next token that will be returned from * {@link #launchNewBrowser(String, long)}. */ private int nextToken = 1; /** * Master map of tokens onto ProcessManagers managing live processes. Also * serves as a lock that must be held before any state-changing operations on * this class may be performed. */ private final Map<Integer, BrowserManagerProcess> processByToken = new HashMap<Integer, BrowserManagerProcess>(); /** * Flag that is set if the serialized option is turned on. */ private final boolean serializeFlag; /** * A single shared Timer used by all instances of * {@link BrowserManagerProcess}. */ private final Timer timer = new Timer(); /** * Constructs a manager for a particular shell command. The specified launch * command should be a path to a browser's executable, suitable for passing to * {@link Runtime#exec(java.lang.String)}. It may also include newline * delimited arguments to pass to that executable. The invoked process must * accept a URL as the final command line argument. * * @param launchCmd a command to launch a browser executable * @param serializeFlag if <code>true</code>, serialize instance of browser * processes to only run one at a time */ BrowserManagerServer(String launchCmd, boolean serializeFlag) throws RemoteException { // TODO: It would be nice to test to see if this file exists, but // currently this mechanism allows you to pass in command line arguments // and it will be a pain to accommodate this. this.launchCmd = launchCmd; this.serializeFlag = serializeFlag; } /** * @see BrowserManager#keepAlive(int, long) */ public void keepAlive(int token, long keepAliveMs) { if (keepAliveMs <= 0) { throw new IllegalArgumentException(); } synchronized (processByToken) { // Is the token one we've issued? if (token < 0 || token >= nextToken) { throw new IllegalArgumentException(); } BrowserManagerProcess process = processByToken.get(token); if (process != null) { if (process.keepAlive(keepAliveMs)) { // The process was successfully kept alive. return; } } else if (launchCommandQueue.contains(new LaunchCommand(token))) { // Nothing to do, the command hasn't started yet. return; } // The process is already dead. Fall through to failure. } throw new IllegalStateException("Process " + token + " already dead"); } /** * @see BrowserManager#killBrowser(int) */ public void killBrowser(int token) { synchronized (processByToken) { // Is the token one we've issued? if (token < 0 || token >= nextToken) { throw new IllegalArgumentException(); } BrowserManagerProcess process = processByToken.get(token); if (process != null) { logger.info("Client kill for active browser: " + token); process.killBrowser(); } else if (launchCommandQueue.contains(new LaunchCommand(token))) { launchCommandQueue.remove(new LaunchCommand(token)); logger.info("Client kill for waiting browser: " + token); } else { logger.info("Client kill for inactive browser: " + token); } } } /** * @see BrowserManager#launchNewBrowser(java.lang.String, long) */ public int launchNewBrowser(String url, long keepAliveMs) { logger.info("Launching browser for url: " + url + " keepAliveMs: " + keepAliveMs); if (url == null || keepAliveMs <= 0) { throw new IllegalArgumentException(); } try { synchronized (processByToken) { int myToken = nextToken++; // Adds self to processByToken. if (serializeFlag && !processByToken.isEmpty()) { // Queue up a launch request if one is already running. launchCommandQueue.add(new LaunchCommand(myToken, url, keepAliveMs)); logger.info("Queuing up request token: " + myToken + " for url: " + url + ". Another launch command is active."); } else { execChild(myToken, url, keepAliveMs); } return myToken; } } catch (IOException e) { logger.log(Level.SEVERE, "Error launching browser '" + launchCmd + "' for '" + url + "'", e); throw new RuntimeException("Error launching browser '" + launchCmd + "' for '" + url + "'", e); } } /** * This method is mainly in place for writing assertions in the unit test. * * @return number of tasks waiting to run if serialized option is enabled */ int numQueued() { synchronized (processByToken) { return launchCommandQueue.size(); } } /** * This method is mainly in place for writing assertions in the unit test. * * @return number of launch commands running that have not yet exited. */ int numRunning() { synchronized (processByToken) { return processByToken.size(); } } /** * Actually create a process and run a browser. * * (Assumes that code is already synchronized by processBytoken) * * @param token token value of browser that exited. * @param url command line arguments to pass to the browser * @param keepAliveMs inital keep alive interval in milliseconds */ private void execChild(int token, String url, long keepAliveMs) throws IOException { // Tokenize the launchCmd by carriage returns (used for unit testing). StringTokenizer st = new StringTokenizer(launchCmd, "\n"); int userTokens = st.countTokens(); String[] cmdarray = new String[userTokens + 1]; for (int i = 0; st.hasMoreTokens(); i++) { cmdarray[i] = st.nextToken(); } // Append the user-specified URL. cmdarray[userTokens] = url; // Start the task. Process child = Runtime.getRuntime().exec(cmdarray); BrowserManagerProcess bmp = new BrowserManagerProcess(childExitCallback, timer, token, child, keepAliveMs); processByToken.put(token, bmp); } /** * If serialization is enabled on the server, kicks off the next queued * command on the delayed command queue. * * (Assumes that code is already synchronized by processBytoken) */ private void launchDelayedCommand() { if (!serializeFlag || !processByToken.isEmpty()) { // No need to launch if serialization is off or // something is already running return; } // Loop through the commands until we can launch one // successfully. while (!launchCommandQueue.isEmpty()) { LaunchCommand lc = launchCommandQueue.remove(); try { execChild(lc.token, lc.url, lc.keepAliveMsecs); // No exception? Great! logger.info("Started delayed browser: " + lc.token); return; } catch (IOException e) { logger.log(Level.SEVERE, "Error launching browser '" + launchCmd + "' for '" + lc.url + "'", e); throw new RuntimeException("Error launching browser '" + launchCmd + "' for '" + lc.url + "'", e); } // If an exception occurred, keep pulling cmds off the queue. } } }