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);
+  }
+
+}