001: /*
002: * Copyright 2007 Google Inc.
003: *
004: * Licensed under the Apache License, Version 2.0 (the "License"); you may not
005: * use this file except in compliance with the License. You may obtain a copy of
006: * the License at
007: *
008: * http://www.apache.org/licenses/LICENSE-2.0
009: *
010: * Unless required by applicable law or agreed to in writing, software
011: * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
012: * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
013: * License for the specific language governing permissions and limitations under
014: * the License.
015: */
016: package com.google.gwt.junit.remote;
017:
018: import java.io.IOException;
019: import java.rmi.Naming;
020: import java.rmi.RemoteException;
021: import java.rmi.registry.LocateRegistry;
022: import java.rmi.registry.Registry;
023: import java.rmi.server.UnicastRemoteObject;
024: import java.util.HashMap;
025: import java.util.Map;
026: import java.util.Timer;
027: import java.util.TimerTask;
028:
029: /**
030: * Manages instances of a web browser as child processes. This class is
031: * experimental and unsupported. An instance of this class can create browser
032: * windows using one specific shell-level command. It performs process
033: * managagement (babysitting) on behalf of a remote client. This can be useful
034: * for running a GWTTestCase on a browser that cannot be run on the native
035: * platform. For example, a GWTTestCase test running on Linux could use a remote
036: * call to a Windows machine to test with Internet Explorer.
037: *
038: * <p>
039: * Calling {@link #main(String[])} can instantiate and register multiple
040: * instances of this class at given RMI namespace locations.
041: * </p>
042: *
043: * <p>
044: * This system has been tested on Internet Explorer 6. Firefox does not work in
045: * the general case; if an existing Firefox process is already running, new
046: * processes simply delegate to the existing process and terminate, which breaks
047: * the model. Safari on MacOS requires very special treatment given Safari's
048: * poor command line support, but that is beyond the scope of this
049: * documentation.
050: * </p>
051: *
052: * <p>
053: * TODO(scottb): We technically need a watchdog thread to slurp up stdout and
054: * stderr from the child processes, or they might block. However, most browsers
055: * never write to stdout and stderr, so this is low priority.
056: * </p>
057: *
058: * see http://bugs.sun.com/bugdatabase/view_bug.do;:YfiG?bug_id=4062587
059: */
060: public class BrowserManagerServer extends UnicastRemoteObject implements
061: BrowserManager {
062:
063: /**
064: * Implementation notes: <code>processByToken</code> must be locked before
065: * performing any state-changing operations.
066: */
067:
068: /**
069: * Manages one web browser child process. This class contains a TimerTask
070: * which tries to kill the managed process.
071: *
072: * Invariants:
073: * <ul>
074: * <li> If process is alive, this manager is in <code>processByToken</code>.
075: * </li>
076: * <li> If process is dead, this manager <i>might</i> be in
077: * <code>processByToken</code>. It will be observed to be dead next time
078: * {@link #keepAlive(long)} or {@link #doKill()} are called. </li>
079: * <li> Calling {@link #keepAlive(long)} and {@link #doKill()} require the
080: * lock on <code>processByToken</code> to be held, so they cannot be called
081: * at the same time. </li>
082: * </ul>
083: */
084: private final class ProcessManager {
085:
086: /**
087: * Kills the child process when fired, unless it is no longer the active
088: * {@link ProcessManager#killTask} in its outer ProcessManager.
089: */
090: private final class KillTask extends TimerTask {
091: /*
092: * @see java.lang.Runnable#run()
093: */
094: @Override
095: public void run() {
096: synchronized (processByToken) {
097: /*
098: * CORNER CASE: Verify we're still the active KillTask, because it's
099: * possible we were bumped out by a keepAlive call after our execution
100: * started but before we could grab the lock on processByToken.
101: */
102: if (killTask == this ) {
103: doKill();
104: }
105: }
106: }
107: }
108:
109: /**
110: * The key associated with <code>process</code> in
111: * <code>processByToken</code>.
112: */
113: private Integer key;
114:
115: /**
116: * If non-null, the active TimerTask which will kill <code>process</code>
117: * when it fires.
118: */
119: private KillTask killTask;
120:
121: /**
122: * The managed child process.
123: */
124: private final Process process;
125:
126: /**
127: * Constructs a new ProcessManager for the specified process, and adds
128: * itself to <code>processByToken</code> using the supplied key. You must
129: * hold the lock on <code>processByToken</code> to call this method.
130: *
131: * @param key the key to be used when adding the new object to
132: * <code>processByToken</code>
133: * @param process the process being managed
134: * @param initKeepAliveMs the initial time to wait before killing
135: * <code>process</code>
136: */
137: ProcessManager(Integer key, Process process,
138: long initKeepAliveMs) {
139: this .process = process;
140: this .key = key;
141: schedule(initKeepAliveMs);
142: processByToken.put(key, this );
143: }
144:
145: /**
146: * Kills the managed process. You must hold the lock on
147: * <code>processByToken</code> to call this method.
148: */
149: public void doKill() {
150: ProcessManager removed = processByToken.remove(key);
151: assert (removed == this );
152: process.destroy();
153: schedule(0);
154: }
155:
156: /**
157: * Keeps the underlying process alive for <code>keepAliveMs</code>
158: * starting now. If the managed process is already dead, cleanup is
159: * performed and the method return false. You must hold the lock on
160: * <code>processByToken</code> to call this method.
161: *
162: * @param keepAliveMs the time to wait before killing the underlying process
163: * @return <code>true</code> if the process was successfully kept alive,
164: * <code>false</code> if the process is already dead.
165: */
166: public boolean keepAlive(long keepAliveMs) {
167: try {
168: /*
169: * See if the managed process is still alive. WEIRD: The only way to
170: * check the process's liveness appears to be asking for its exit status
171: * and seeing whether it throws an IllegalThreadStateException.
172: */
173: process.exitValue();
174: } catch (IllegalThreadStateException e) {
175: // The process is still alive.
176: schedule(keepAliveMs);
177: return true;
178: }
179:
180: // The process is dead already; perform cleanup.
181: doKill();
182: return false;
183: }
184:
185: /**
186: * Cancels any existing kill task and optionally schedules a new one to run
187: * <code>keepAliveMs</code> from now. You must hold the lock on
188: * <code>processByToken</code> to call this method.
189: *
190: * @param keepAliveMs if > 0, schedules a new kill task to run in
191: * keepAliveMs milliseconds; if <= 0, a new kill task is not
192: * scheduled.
193: */
194: private void schedule(long keepAliveMs) {
195: if (killTask != null) {
196: killTask.cancel();
197: killTask = null;
198: }
199: if (keepAliveMs > 0) {
200: killTask = new KillTask();
201: timer.schedule(killTask, keepAliveMs);
202: }
203: }
204: }
205:
206: /**
207: * Starts up and registers one or more browser servers. Command-line entry
208: * point.
209: */
210: public static void main(String[] args) throws Exception {
211: if (args.length == 0) {
212: System.err
213: .println(""
214: + "Manages local browser windows for a remote client using RMI.\n"
215: + "\n"
216: + "Pass in an even number of args, at least 2. The first argument\n"
217: + "is a short registration name, and the second argument is the\n"
218: + "executable to run when that name is used; for example,\n"
219: + "\n"
220: + "\tie6 \"C:\\Program Files\\Internet Explorer\\IEXPLORE.EXE\"\n"
221: + "\n"
222: + "would register Internet Explorer to \"rmi://localhost/ie6\".\n"
223: + "The third and fourth arguments make another pair, and so on.\n");
224: System.exit(1);
225: }
226:
227: if (args.length < 2) {
228: throw new IllegalArgumentException(
229: "Need at least 2 arguments");
230: }
231:
232: if (args.length % 2 != 0) {
233: throw new IllegalArgumentException(
234: "Need an even number of arguments");
235: }
236:
237: // Create an RMI registry so we don't need an external process.
238: // Uses the default RMI port.
239: // TODO(scottb): allow user to override the port via command line option.
240: LocateRegistry.createRegistry(Registry.REGISTRY_PORT);
241: System.out.println("RMI registry ready.");
242:
243: for (int i = 0; i < args.length; i += 2) {
244: BrowserManagerServer bms = new BrowserManagerServer(
245: args[i + 1]);
246: Naming.rebind(args[i], bms);
247: System.out.println(args[i]
248: + " started and awaiting connections");
249: }
250: }
251:
252: /**
253: * The shell command to launch when a new browser is requested.
254: */
255: private final String launchCmd;
256:
257: /**
258: * The next token that will be returned from
259: * {@link #launchNewBrowser(String, long)}.
260: */
261: private int nextToken = 1;
262:
263: /**
264: * Master map of tokens onto ProcessManagers managing live processes. Also
265: * serves as a lock that must be held before any state-changing operations on
266: * this class may be performed.
267: */
268: private final Map<Integer, ProcessManager> processByToken = new HashMap<Integer, ProcessManager>();
269:
270: /**
271: * A single shared Timer used by all instances of
272: * {@link ProcessManager.KillTask}.
273: */
274: private final Timer timer = new Timer();
275:
276: /**
277: * Constructs a manager for a particular shell command.
278: *
279: * @param launchCmd the path to a browser's executable, suitable for passing
280: * to {@link Runtime#exec(java.lang.String)}. The invoked process
281: * must accept a URL as a command line argument.
282: */
283: public BrowserManagerServer(String launchCmd)
284: throws RemoteException {
285: this .launchCmd = launchCmd;
286: }
287:
288: /*
289: * @see BrowserManager#keepAlive(int, long)
290: */
291: public void keepAlive(int token, long keepAliveMs) {
292:
293: if (keepAliveMs <= 0) {
294: throw new IllegalArgumentException();
295: }
296:
297: synchronized (processByToken) {
298: // Is the token one we've issued?
299: if (token < 0 || token >= nextToken) {
300: throw new IllegalArgumentException();
301: }
302: ProcessManager process = processByToken.get(token);
303: if (process != null) {
304: if (process.keepAlive(keepAliveMs)) {
305: // The process was successfully kept alive.
306: return;
307: } else {
308: // The process is already dead. Fall through to failure.
309: }
310: }
311: }
312:
313: throw new IllegalStateException("Process " + token
314: + " already dead");
315: }
316:
317: /*
318: * @see BrowserManager#killBrowser(int)
319: */
320: public void killBrowser(int token) {
321: synchronized (processByToken) {
322: // Is the token one we've issued?
323: if (token < 0 || token >= nextToken) {
324: throw new IllegalArgumentException();
325: }
326: ProcessManager process = processByToken.get(token);
327: if (process != null) {
328: process.doKill();
329: }
330: }
331: }
332:
333: /*
334: * @see BrowserManager#launchNewBrowser(java.lang.String, long)
335: */
336: public int launchNewBrowser(String url, long keepAliveMs) {
337:
338: if (url == null || keepAliveMs <= 0) {
339: throw new IllegalArgumentException();
340: }
341:
342: try {
343: Process child = Runtime.getRuntime().exec(
344: new String[] { launchCmd, url });
345: synchronized (processByToken) {
346: int myToken = nextToken++;
347: // Adds self to processByToken.
348: new ProcessManager(myToken, child, keepAliveMs);
349: return myToken;
350: }
351: } catch (IOException e) {
352: throw new RuntimeException("Error launching browser '"
353: + launchCmd + "' for '" + url + "'", e);
354: }
355: }
356: }
|