Support BrowserManagerServer running FireFox where we have a restriction of running only one instance of the browser at a time. This change does the following: - Refactors the server into 3 separate class files - Adds command line argument parsing by extending ToolBase - Adds a -port option so if we want to start a second server, we can do so on a different port - Adds a -serialize option to enforce that only one instance of a browser runs at a time per launch configuration - Adds a queue to keep track of deferred launch commands - Adds a thread per process launched to wait for the exec'ed browser to complete and then kick off any deferred launch commands when done. - Adds logging using java.util.loggging - By default, the daemon is much more chatty than it used to be. - This can be configured in the standard java.util.logging way but I haven't created a properties file to change the default behavior. - Adds a unit test to show that the -serialize option works and that when it is off browsers are not serialized. Patch by: zundel Review by: me git-svn-id: https://google-web-toolkit.googlecode.com/svn/trunk@1854 8db76d5a-ed1c-0410-87a9-c151d255dfc7
diff --git a/dev/core/src/com/google/gwt/util/tools/ToolBase.java b/dev/core/src/com/google/gwt/util/tools/ToolBase.java index f1f5376..d5bb18f 100644 --- a/dev/core/src/com/google/gwt/util/tools/ToolBase.java +++ b/dev/core/src/com/google/gwt/util/tools/ToolBase.java
@@ -70,6 +70,10 @@ // private final List orderedArgHandlers = new ArrayList(); + protected String getDescription() { + return null; + } + protected void printHelp() { System.err.println(About.GWT_VERSION); @@ -139,6 +143,11 @@ } System.err.println(); + String description = getDescription(); + if (description != null) { + System.err.println(description); + System.err.println(); + } System.err.println("where ");
diff --git a/user/src/com/google/gwt/junit/remote/BrowserManagerProcess.java b/user/src/com/google/gwt/junit/remote/BrowserManagerProcess.java new file mode 100644 index 0000000..781c9b3 --- /dev/null +++ b/user/src/com/google/gwt/junit/remote/BrowserManagerProcess.java
@@ -0,0 +1,286 @@ +/* + * 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 java.text.NumberFormat; +import java.util.Timer; +import java.util.TimerTask; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Manages one web browser child process. This class contains a TimerTask which + * tries to kill the managed process. A thread is created for each task to wait + * for the process to exit and give a callback. + * + * Invariants: + * <ul> + * <li> Most of this code executes in a separate thread per process. Thus, the + * API entry points lock <code>this</code></li> + * <li> The lock on this is removed before calling the <code>childExited</code> + * callback. This prevents potential deadlock. + * </ul> + */ +class BrowserManagerProcess { + + /** + * Used to notify the caller of the constructor when a process exits. Note + * that the childExited() method is called from a different thread than the + * one that created the process. + */ + public interface ProcessExitCb { + void childExited(int key, int exitValue); + } + + /** + * Kills the child process when fired, unless it is no longer the active + * {@link BrowserManagerProcess#killTask} in its outer ProcessManager. + */ + private final class KillTask extends TimerTask { + @Override + public void run() { + synchronized (BrowserManagerProcess.this) { + /* + * CORNER CASE: Verify we're still the active KillTask, because it's + * possible we were bumped out by a keepAlive call after our execution + * started but before we could grab the lock. + */ + if (killTask == this) { + logger.info("Timeout expired for task: " + token); + doKill(); + } + } + } + } + + private static final Logger logger = Logger.getLogger(BrowserManagerProcess.class.getName()); + + /** + * Exit callback when the process exits. + */ + private final ProcessExitCb cb; + + /** + * Set to 'true' when the process exits. + */ + private boolean exited = false; + + /** + * Set to the exitValue() of the process when it actually exits. + */ + private int exitValue = -1; + + /** + * The key associated with <code>process</code>. + */ + private final int token; + + /** + * If non-null, the active TimerTask which will kill <code>process</code> + * when it fires. + */ + private KillTask killTask; + + /** + * The managed child process. + */ + private final Process process; + + /** + * Time the exec'ed child actually started. + */ + private final long startTime; + + /** + * Timer instance passed in from BrowserManagerServer. + */ + private final Timer timer; + + /** + * Constructs a new ProcessManager for the specified process. + * + * @param timer timer passed in from BrowserManagerServer instance. + * @param token the key to be used to identify this process. + * @param process the process being managed + * @param initKeepAliveMs the initial time to wait before killing + * <code>process</code> + */ + BrowserManagerProcess(ProcessExitCb cb, Timer timer, final int token, + final Process process, long initKeepAliveMs) { + this.cb = cb; + this.timer = timer; + this.process = process; + this.token = token; + schedule(initKeepAliveMs); + startTime = System.currentTimeMillis(); + + Thread cleanupThread = new Thread() { + @Override + public void run() { + try { + exitValue = process.waitFor(); + if (cleanupBrowser() != 0) { + logger.warning("Browser " + token + "exited with bad status: " + + exitValue); + } else { + logger.info("Browser " + token + " process exited normally. " + + getElapsed() + " milliseconds."); + } + } catch (InterruptedException e) { + logger.log(Level.WARNING, "Couldn't wait for process exit. token: " + + token, e); + } + } + }; + + cleanupThread.setDaemon(true); + cleanupThread.setName("Browser-" + token + "-Wait"); + cleanupThread.start(); + } + + /** + * Kills the managed process. + * + * @return the exit value of the task. + */ + public int doKill() { + + boolean doCleanup = false; + synchronized (this) { + if (!exited) { + logger.info("Killing browser process for " + this.token); + process.destroy(); + + // Wait for the process to exit. + try { + exitValue = process.waitFor(); + doCleanup = true; + } catch (InterruptedException ie) { + logger.severe("Interrupted waiting for browser " + token + + " exit during kill."); + } + } + } + + // Run cleanupBrowser() outside the critical section. + if (doCleanup) { + if (cleanupBrowser() != 0) { + logger.warning("Kill Browser " + token + "exited with bad status: " + + exitValue); + + } else { + logger.info("Kill Browser " + token + " process exited normally. " + + getElapsed() + " milliseconds."); + } + } + + return exitValue; + } + + /** + * Keeps the underlying process alive for <code>keepAliveMs</code> starting + * now. If the managed process is already dead, cleanup is performed and the + * method return false. + * + * @param keepAliveMs the time to wait before killing the underlying process + * @return <code>true</code> if the process was successfully kept alive, + * <code>false</code> if the process is already dead. + */ + public boolean keepAlive(long keepAliveMs) { + synchronized (this) { + try { + /* + * See if the managed process is still alive. WEIRD: The only way to + * check the process's liveness appears to be asking for its exit status + * and seeing whether it throws an IllegalThreadStateException. + */ + process.exitValue(); + } catch (IllegalThreadStateException e) { + // The process is still alive. + schedule(keepAliveMs); + return true; + } + } + + // The process is dead already; perform cleanup. + cleanupBrowser(); + return false; + } + + /** + * Routine that informs the BrowserManagerServer of the exit status once and + * only once. + * + * This should be called WITHOUT the lock on BrowserManagerProcess.this being + * held. + * + * @return The exit value returned by the process when it exited. + */ + private int cleanupBrowser() { + boolean doCb = false; + synchronized (this) { + if (!exited) { + exited = true; + exitValue = process.exitValue(); + // Stop the timer for this thread. + schedule(0); + doCb = true; + } + } + + /* + * Callback must occur without holding my own lock. This is because the + * callee will try to acquire the lock on + * BrowserManagerServer.processByToken. If another thread already has that + * lock and is tries to lock me at the same time, a deadlock would ensure. + */ + if (doCb) { + cb.childExited(token, exitValue); + } + + return exitValue; + } + + /** + * Compute elapsed time. + * + * @return returns a string representing the number of seconds elapsed since + * the process started. + */ + private synchronized String getElapsed() { + NumberFormat nf = NumberFormat.getNumberInstance(); + nf.setMaximumFractionDigits(3); + return nf.format((System.currentTimeMillis() - startTime) / 1000.0); + } + + /** + * Cancels any existing kill task and optionally schedules a new one to run + * <code>keepAliveMs</code> from now. + * + * @param keepAliveMs if > 0, schedules a new kill task to run in keepAliveMs + * milliseconds; if <= 0, a new kill task is not scheduled. + */ + private void schedule(long keepAliveMs) { + if (killTask != null) { + killTask.cancel(); + killTask = null; + } + if (keepAliveMs > 0) { + killTask = new KillTask(); + timer.schedule(killTask, keepAliveMs); + } + } +}
diff --git a/user/src/com/google/gwt/junit/remote/BrowserManagerServer.java b/user/src/com/google/gwt/junit/remote/BrowserManagerServer.java index befb97d..9a39822 100644 --- a/user/src/com/google/gwt/junit/remote/BrowserManagerServer.java +++ b/user/src/com/google/gwt/junit/remote/BrowserManagerServer.java
@@ -1,5 +1,5 @@ /* - * Copyright 2007 Google Inc. + * 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 @@ -15,22 +15,25 @@ */ package com.google.gwt.junit.remote; +import com.google.gwt.junit.remote.BrowserManagerProcess.ProcessExitCb; + import java.io.IOException; -import java.rmi.Naming; import java.rmi.RemoteException; -import java.rmi.registry.LocateRegistry; -import java.rmi.registry.Registry; 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.TimerTask; +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 - * managagement (babysitting) on behalf of a remote client. This can be useful + * 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. @@ -41,18 +44,21 @@ * </p> * * <p> - * This system has been tested on Internet Explorer 6. Firefox does not work in - * the general case; if an existing Firefox process is already running, new + * 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. Safari on MacOS requires very special treatment given Safari's - * poor command line support, but that is beyond the scope of this - * documentation. + * 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. + * 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> * * see http://bugs.sun.com/bugdatabase/view_bug.do;:YfiG?bug_id=4062587 @@ -66,188 +72,86 @@ */ /** - * Manages one web browser child process. This class contains a TimerTask - * which tries to kill the managed process. - * - * Invariants: - * <ul> - * <li> If process is alive, this manager is in <code>processByToken</code>. - * </li> - * <li> If process is dead, this manager <i>might</i> be in - * <code>processByToken</code>. It will be observed to be dead next time - * {@link #keepAlive(long)} or {@link #doKill()} are called. </li> - * <li> Calling {@link #keepAlive(long)} and {@link #doKill()} require the - * lock on <code>processByToken</code> to be held, so they cannot be called - * at the same time. </li> - * </ul> + * Entry in the launchCommandQueue to use when tasks are serialized. */ - private final class ProcessManager { + private class LaunchCommand { + long keepAliveMsecs; + int token; + String url; - /** - * Kills the child process when fired, unless it is no longer the active - * {@link ProcessManager#killTask} in its outer ProcessManager. - */ - private final class KillTask extends TimerTask { - /* - * @see java.lang.Runnable#run() - */ - @Override - public void run() { - synchronized (processByToken) { - /* - * CORNER CASE: Verify we're still the active KillTask, because it's - * possible we were bumped out by a keepAlive call after our execution - * started but before we could grab the lock on processByToken. - */ - if (killTask == this) { - doKill(); - } - } - } + LaunchCommand(int tokenIn) { + this(tokenIn, null, 0); } - /** - * The key associated with <code>process</code> in - * <code>processByToken</code>. - */ - private Integer key; - - /** - * If non-null, the active TimerTask which will kill <code>process</code> - * when it fires. - */ - private KillTask killTask; - - /** - * The managed child process. - */ - private final Process process; - - /** - * Constructs a new ProcessManager for the specified process, and adds - * itself to <code>processByToken</code> using the supplied key. You must - * hold the lock on <code>processByToken</code> to call this method. - * - * @param key the key to be used when adding the new object to - * <code>processByToken</code> - * @param process the process being managed - * @param initKeepAliveMs the initial time to wait before killing - * <code>process</code> - */ - ProcessManager(Integer key, Process process, long initKeepAliveMs) { - this.process = process; - this.key = key; - schedule(initKeepAliveMs); - processByToken.put(key, this); + LaunchCommand(int tokenIn, String urlIn, long keepAliveMsecsIn) { + token = tokenIn; + url = urlIn; + keepAliveMsecs = keepAliveMsecsIn; } - /** - * Kills the managed process. You must hold the lock on - * <code>processByToken</code> to call this method. - */ - public void doKill() { - ProcessManager removed = processByToken.remove(key); - assert (removed == this); - process.destroy(); - schedule(0); - } - - /** - * Keeps the underlying process alive for <code>keepAliveMs</code> - * starting now. If the managed process is already dead, cleanup is - * performed and the method return false. You must hold the lock on - * <code>processByToken</code> to call this method. - * - * @param keepAliveMs the time to wait before killing the underlying process - * @return <code>true</code> if the process was successfully kept alive, - * <code>false</code> if the process is already dead. - */ - public boolean keepAlive(long keepAliveMs) { - try { - /* - * See if the managed process is still alive. WEIRD: The only way to - * check the process's liveness appears to be asking for its exit status - * and seeing whether it throws an IllegalThreadStateException. - */ - process.exitValue(); - } catch (IllegalThreadStateException e) { - // The process is still alive. - schedule(keepAliveMs); + @Override + public boolean equals(Object obj) { + if (obj instanceof LaunchCommand && ((LaunchCommand) obj).token == token) { return true; } - - // The process is dead already; perform cleanup. - doKill(); return false; } - /** - * Cancels any existing kill task and optionally schedules a new one to run - * <code>keepAliveMs</code> from now. You must hold the lock on - * <code>processByToken</code> to call this method. - * - * @param keepAliveMs if > 0, schedules a new kill task to run in - * keepAliveMs milliseconds; if <= 0, a new kill task is not - * scheduled. - */ - private void schedule(long keepAliveMs) { - if (killTask != null) { - killTask.cancel(); - killTask = null; - } - if (keepAliveMs > 0) { - killTask = new KillTask(); - timer.schedule(killTask, keepAliveMs); - } + @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 { - if (args.length == 0) { - System.err.println("" - + "Manages local browser windows for a remote client using RMI.\n" - + "\n" - + "Pass in an even number of args, at least 2. The first argument\n" - + "is a short registration name, and the second argument is the\n" - + "executable to run when that name is used; for example,\n" + "\n" - + "\tie6 \"C:\\Program Files\\Internet Explorer\\IEXPLORE.EXE\"\n" - + "\n" - + "would register Internet Explorer to \"rmi://localhost/ie6\".\n" - + "The third and fourth arguments make another pair, and so on.\n"); - System.exit(1); - } - if (args.length < 2) { - throw new IllegalArgumentException("Need at least 2 arguments"); - } - - if (args.length % 2 != 0) { - throw new IllegalArgumentException("Need an even number of arguments"); - } - - // Create an RMI registry so we don't need an external process. - // Uses the default RMI port. - // TODO(scottb): allow user to override the port via command line option. - LocateRegistry.createRegistry(Registry.REGISTRY_PORT); - System.out.println("RMI registry ready."); - - for (int i = 0; i < args.length; i += 2) { - BrowserManagerServer bms = new BrowserManagerServer(args[i + 1]); - Naming.rebind(args[i], bms); - System.out.println(args[i] + " started and awaiting connections"); + // 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)}. */ @@ -258,27 +162,40 @@ * serves as a lock that must be held before any state-changing operations on * this class may be performed. */ - private final Map<Integer, ProcessManager> processByToken = - new HashMap<Integer, ProcessManager>(); + 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 ProcessManager.KillTask}. + * {@link BrowserManagerProcess}. */ private final Timer timer = new Timer(); /** - * Constructs a manager for a particular shell command. + * 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 the path to a browser's executable, suitable for passing - * to {@link Runtime#exec(java.lang.String)}. The invoked process - * must accept a URL as a 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 */ - public BrowserManagerServer(String launchCmd) throws RemoteException { + 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) { @@ -292,56 +209,166 @@ if (token < 0 || token >= nextToken) { throw new IllegalArgumentException(); } - ProcessManager process = processByToken.get(token); + BrowserManagerProcess process = processByToken.get(token); if (process != null) { if (process.keepAlive(keepAliveMs)) { // The process was successfully kept alive. return; - } else { - // The process is already dead. Fall through to failure. } + } 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(); } - ProcessManager process = processByToken.get(token); + BrowserManagerProcess process = processByToken.get(token); if (process != null) { + logger.fine("Killing browser. token=" + token); process.doKill(); + } else if (launchCommandQueue.contains(new LaunchCommand(token))) { + launchCommandQueue.remove(new LaunchCommand(token)); + logger.info(token + " removed from delayed launch queue."); + } else { + logger.fine("No action taken. Browser not active for token " + 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 { - Process child = Runtime.getRuntime().exec(new String[] {launchCmd, url}); synchronized (processByToken) { int myToken = nextToken++; // Adds self to processByToken. - new ProcessManager(myToken, child, keepAliveMs); + + 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. + } + } }
diff --git a/user/src/com/google/gwt/junit/remote/BrowserManagerServerLauncher.java b/user/src/com/google/gwt/junit/remote/BrowserManagerServerLauncher.java new file mode 100644 index 0000000..b383b62 --- /dev/null +++ b/user/src/com/google/gwt/junit/remote/BrowserManagerServerLauncher.java
@@ -0,0 +1,230 @@ +/* + * 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.util.tools.ArgHandler; +import com.google.gwt.util.tools.ArgHandlerFlag; +import com.google.gwt.util.tools.ArgHandlerString; +import com.google.gwt.util.tools.ToolBase; + +import java.rmi.RemoteException; +import java.rmi.registry.LocateRegistry; +import java.rmi.registry.Registry; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * + * Used to process arguments and start up instances of BrowserManagerServer. + * Some of this logic used to be in BrowserManagerServer.main() and was moved + * here so we could inherit argument parsing from the ToolBase class. + */ +class BrowserManagerServerLauncher extends ToolBase { + + private final class ArgHandlerPort extends ArgHandlerString { + + @Override + public String getPurpose() { + return "Controls the port for the RMI invocation (defaults to " + + Registry.REGISTRY_PORT + ")"; + } + + @Override + public String getTag() { + return "-port"; + } + + @Override + public String[] getTagArgs() { + return new String[] {"port-number"}; + } + + @Override + public boolean isRequired() { + return false; + } + + @Override + public boolean setString(String value) { + try { + portArg = Integer.parseInt(value); + } catch (NumberFormatException e) { + logger.severe("The -port argument must be an integer value. Got: " + + value); + return false; + } + return true; + } + } + + /** + * Handles the list of registration ids / machine names passed on the command + * line. + */ + private class ArgHandlerRegistration extends ArgHandler { + + @Override + public String[] getDefaultArgs() { + return null; + } + + @Override + public String getPurpose() { + return "Specify two arguments: a registration id used for the " + + "RMI call and the browser launch command"; + } + + @Override + public String getTag() { + return null; + } + + @Override + public String[] getTagArgs() { + return new String[] {"registration-id", "path-to-browser-executable"}; + } + + @Override + public int handle(String[] args, int startIndex) { + // Consume 2 arguments + if (args.length >= startIndex + 2) { + BMSEntry entry = new BMSEntry(args[startIndex], args[startIndex + 1]); + bmsList.add(entry); + return 1; + } + return -1; + } + + @Override + public boolean isRequired() { + return true; + } + } + + private class BMSEntry { + final String browserPath; + final String registrationKey; + + BMSEntry(String registrationKeyIn, String browserPathIn) { + registrationKey = registrationKeyIn; + browserPath = browserPathIn; + } + } + + private static final Logger logger = Logger.getLogger(BrowserManagerServerLauncher.class.getName()); + + private static final String USAGE = "" + + "Manages local browser windows for a remote client using RMI.\n" + "\n" + + "Pass in an even number of args, at least 2. The first argument\n" + + "is a short registration name, and the second argument is the\n" + + "executable to run when that name is used; for example,\n" + "\n" + + "\tie6 \"C:\\Program Files\\Internet Explorer\\IEXPLORE.EXE\"\n" + "\n" + + "would register Internet Explorer to \"rmi://localhost/ie6\".\n" + + "The third and fourth arguments make another pair, and so on.\n"; + + private List<BMSEntry> bmsList = new ArrayList<BMSEntry>(); + private int portArg = Registry.REGISTRY_PORT; + private boolean serializeArg = false; + + /** + * Creates a new BrowserServerLauncher and registers argument handling + * instances. + */ + BrowserManagerServerLauncher() { + registerHandler(new ArgHandlerPort()); + registerHandler(new ArgHandlerRegistration()); + registerHandler(new ArgHandlerFlag() { + + @Override + public String getPurpose() { + return "Queue up requests to a single server so that only a single " + + "test runs at a time (Usefule for a simple Firefox setup.)\n"; + } + + @Override + public String getTag() { + return "-serialize"; + } + + @Override + public boolean setFlag() { + serializeArg = true; + return true; + } + + }); + } + + public boolean doProcessArgs(String[] args) { + if (args.length == 0) { + System.err.println(USAGE); + return false; + } + return processArgs(args); + } + + /** + * This method should be invoked after argument parsing completes. + */ + public void run() { + Registry rmiRegistry = null; + + try { + // Create an RMI registry so we don't need an external process. + // Uses the default RMI port if no port is specified with the -port arg. + rmiRegistry = LocateRegistry.createRegistry(portArg); + } catch (RemoteException e) { + logger.log(Level.SEVERE, "Couldn't bind RMI Registry to port " + portArg, + e); + System.exit(1); + } + + logger.log(Level.ALL, "RMI registry ready on port " + portArg + "."); + + // Startup each of the registered servers on this machine. + for (BMSEntry entry : bmsList) { + BrowserManagerServer server = null; + try { + server = new BrowserManagerServer(entry.browserPath, serializeArg); + } catch (RemoteException re) { + logger.log(Level.SEVERE, entry.registrationKey + + ": Error starting new BrowserManagerServer.", re); + System.exit(2); + } + + try { + rmiRegistry.rebind(entry.registrationKey, server); + } catch (RemoteException re) { + logger.log(Level.SEVERE, entry.registrationKey + " server: " + server + + " port: " + portArg + " Error on rebind to " + + entry.registrationKey, re); + System.exit(3); + } + logger.log(Level.INFO, "Server: " + entry.registrationKey + + " started and awaiting connections."); + } + + logger.log(Level.INFO, "All servers started."); + } + + @Override + protected String getDescription() { + return USAGE; + } + +}
diff --git a/user/test/com/google/gwt/junit/remote/BrowserManagerServerTest.java b/user/test/com/google/gwt/junit/remote/BrowserManagerServerTest.java new file mode 100644 index 0000000..a6628d6 --- /dev/null +++ b/user/test/com/google/gwt/junit/remote/BrowserManagerServerTest.java
@@ -0,0 +1,234 @@ +/* + * 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 junit.framework.TestCase; + +import java.io.File; +import java.net.MalformedURLException; +import java.rmi.Naming; +import java.rmi.NotBoundException; +import java.rmi.RemoteException; +import java.rmi.registry.LocateRegistry; +import java.rmi.registry.Registry; +import java.util.Properties; + +/** + * Exercise the BrowserManagerServer with the serialized option turned on and + * off. + */ +public class BrowserManagerServerTest extends TestCase { + /** + * Seconds for simulated browser to hang. + */ + static final int TIMEOUT_MS = 2000; + /** + * Time to wait between keepAlive calls to all browsers. + */ + static final int PING_INTERVAL_MS = 100; + static final boolean LOG = false; + + private static final String REGISTRATION_KEY = "sleep-" + (TIMEOUT_MS / 1000) + + "-seconds"; + private static Registry rmiRegistry = null; + + private int portArg = Registry.REGISTRY_PORT; + private BrowserManagerServer server = null; + + /** + * Run a test with the 'serialized' flag on. + */ + public void testSerializedServer() throws Exception { + BrowserManager browserManager = startBrowserManagerServer(true); + + // Launch some browsers all at once. + final int NUM_BROWSERS = 4; + int tokens[] = new int[NUM_BROWSERS]; + + for (int i = 0; i < NUM_BROWSERS; i++) { + tokens[i] = launchBrowser(browserManager, i); + } + + // Give them a chance to startup. + Thread.sleep(TIMEOUT_MS / 4); + + int numQueued = server.numQueued(); + int numRunning = server.numRunning(); + assertEquals("Did not find the number of expected browsers queued up", + NUM_BROWSERS - 1, numQueued); + assertEquals("Expected only one running at a time for serialized.", 1, + numRunning); + + outer : for (int runningBrowser = 0; runningBrowser < NUM_BROWSERS; ++runningBrowser) { + // The current browser should be dead within twice the expected timeout. + long shouldBeDeadBy = System.currentTimeMillis() + (TIMEOUT_MS * 2); + while (System.currentTimeMillis() < shouldBeDeadBy) { + assertTrue(server.numRunning() <= 1); + + // Ping every alive browser. + for (int i = runningBrowser; i < NUM_BROWSERS; ++i) { + // Keep the browser alive with a margin of safety until the next ping. + try { + browserManager.keepAlive(tokens[runningBrowser], TIMEOUT_MS); + } catch (IllegalStateException ise) { + // Expected. + assertEquals("The wrong browser is dead", runningBrowser, i); + if (LOG) { + System.out.println("Browser token: " + tokens[i] + + " exited sucessfully"); + } + // Ensure it's legal to kill the dead browser. + browserManager.killBrowser(tokens[i]); + // Continue with the next active browser. + continue outer; + } + } + Thread.sleep(PING_INTERVAL_MS); + } + // Error case + fail("Browser " + runningBrowser + " failed to exit in a timely manner"); + } + } + + /** + * Run a test with the 'serialized' flag on. + */ + public void testUnserializedServer() throws Exception { + BrowserManager browserManager = startBrowserManagerServer(false); + + // Launch some browsers all at once. + final int NUM_BROWSERS = 6; + int tokens[] = new int[NUM_BROWSERS]; + + for (int i = 0; i < NUM_BROWSERS; i++) { + tokens[i] = launchBrowser(browserManager, i); + } + + // Give them a chance to startup. + Thread.sleep(TIMEOUT_MS / 4); + + int numQueued = server.numQueued(); + int numRunning = server.numRunning(); + assertEquals("No queuing should occur", 0, numQueued); + assertEquals("All browers should be running", NUM_BROWSERS, numRunning); + + // The current browser should be dead within twice the expected timeout. + long shouldBeDeadBy = System.currentTimeMillis() + (TIMEOUT_MS * 2); + int liveBrowsers = NUM_BROWSERS; + while (System.currentTimeMillis() < shouldBeDeadBy) { + // Ping every alive browser. + for (int i = 0; i < NUM_BROWSERS; ++i) { + if (tokens[i] == 0) { + // This one's already dead. + continue; + } + // Keep the browser alive with a margin of safety until the next ping. + try { + browserManager.keepAlive(tokens[i], TIMEOUT_MS); + } catch (IllegalStateException ise) { + // Expected. + if (LOG) { + System.out.println("Browser token: " + tokens[i] + + " exited sucessfully"); + } + // Ensure it's legal to kill the dead browser. + browserManager.killBrowser(tokens[i]); + + tokens[i] = 0; + --liveBrowsers; + + if (liveBrowsers == 0) { + // All done; + return; + } + } + } + Thread.sleep(PING_INTERVAL_MS); + } + // Error case + fail(liveBrowsers + " browsers failed to exit in a timely manner"); + } + + /** + * Start up the RMI registry and create a shell script that just sleeps for + * 'timeout' seconds. + */ + @Override + protected void setUp() throws Exception { + if (rmiRegistry == null) { + rmiRegistry = LocateRegistry.createRegistry(portArg); + } + } + + /** + * Clean up temporary files. + */ + @Override + protected void tearDown() throws Exception { + // De-register the server. + if (rmiRegistry != null) { + rmiRegistry.unbind(REGISTRATION_KEY); + } + } + + /** + * Start a browser task on the server. + * + * @param browserManager handle to the browser manager instance + * @param token browser ident number + */ + private int launchBrowser(BrowserManager browserManager, int token) + throws RemoteException { + return browserManager.launchNewBrowser("# client" + token, TIMEOUT_MS); + } + + /** + * Starts up an instance of BrowserManagerServer. + * + * @param isSerialized true to enable the serialized mode (run one browser + * instance at a time, queue any others.) + * @return the newly created instance of BrowserManagerServer on success + */ + private BrowserManager startBrowserManagerServer(boolean isSerialized) + throws RemoteException, MalformedURLException, NotBoundException { + // Construct a launch command for relaunching the JVM out of process, + // running DummyProcess. + StringBuilder sb = new StringBuilder(); + Properties properties = System.getProperties(); + sb.append(properties.getProperty("java.home")); + sb.append(File.separatorChar); + sb.append("bin"); + sb.append(File.separatorChar); + sb.append("java"); + sb.append('\n'); + + sb.append("-classpath"); + sb.append('\n'); + + sb.append(properties.getProperty("java.class.path")); + sb.append('\n'); + + sb.append(DummyProcess.class.getName()); + + server = new BrowserManagerServer(sb.toString(), isSerialized); + rmiRegistry.rebind(REGISTRATION_KEY, server); + + // Server started. Now, create a client and send some commands to it + String url = "rmi://localhost/" + REGISTRATION_KEY; + BrowserManager browserManager = (BrowserManager) Naming.lookup(url); + return browserManager; + } +}
diff --git a/user/test/com/google/gwt/junit/remote/DummyProcess.java b/user/test/com/google/gwt/junit/remote/DummyProcess.java new file mode 100644 index 0000000..9e32bfa --- /dev/null +++ b/user/test/com/google/gwt/junit/remote/DummyProcess.java
@@ -0,0 +1,27 @@ +/* + * 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; + +/** + * A dummy process to simulate a server-side browser process. + */ +public class DummyProcess { + + public static void main(String[] args) throws InterruptedException { + Thread.sleep(BrowserManagerServerTest.TIMEOUT_MS); + } + +}