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