I have solved the problem with the help of JNA (Java Native Access) 3.4 which was already included with Selenium. My target platform is Windows only, but it shouldn't take much work to make this cross-platform. I had to do the following:
- Collect all browser process IDs before launching WebDriver (this can be done with utilities like
tasklist
or powershell Get-Process
). If you know for certain there will be no browser processes running before launching your application, this step can be omitted.
- Initialize the driver.
- Collect all browser processes again, then take the difference between the two.
- Using the code linked in LittlePanda's comment as a base, we can create a watcher service on a new thread. This will alert all listeners when all the processes have closed.
- The
Kernel32.INSTANCE.OpenProcess
method allows us to create HANDLE
objects from the pids.
- With these handles we can have the thread wait until all processes are signaled with the
Kernel32.INSTANCE.WaitForMultipleObjects
method.
Here's the code I used so that it might help others:
public void startAutomation() throws IOException {
Set<Integer> pidsBefore = getBrowserPIDs(browserType);
automator.initDriver(browserType); //calls new ChromeDriver() for example
Set<Integer> pidsAfter = getBrowserPIDs(browserType);
pidsAfter.removeAll(pidsBefore);
ProcessGroupExitWatcher watcher = new ProcessGroupExitWatcher(pidsAfter);
watcher.addProcessExitListener(new ProcessExitListener() {
@Override
public void processFinished() {
if (automator != null) {
automator.closeDriver(); //calls driver.quit()
automator = null;
}
}
});
watcher.start();
//do webdriver stuff
}
private Set<Integer> getBrowserPIDs(String browserType) throws IOException {
Set<Integer> processIds = new HashSet<Integer>();
//powershell was convenient, tasklist is probably safer but requires more parsing
String cmd = "powershell get-process " + browserType + " | foreach { $_.id }";
Process processes = Runtime.getRuntime().exec(cmd);
processes.getOutputStream().close(); //otherwise powershell hangs
BufferedReader input = new BufferedReader(new InputStreamReader(processes.getInputStream()));
String line;
while ((line = input.readLine()) != null) {
processIds.add(Integer.parseInt(line));
}
input.close();
return processIds;
}
And the code for the watcher:
/**
* Takes a <code>Set</code> of Process IDs and notifies all listeners when all
* of them have exited.<br>
*/
public class ProcessGroupExitWatcher extends Thread {
private List<HANDLE> processHandles;
private List<ProcessExitListener> listeners = new ArrayList<ProcessExitListener>();
/**
* Initializes the object and takes a set of pids and creates a list of
* <CODE>HANDLE</CODE>s from them.
*
* @param processIds
* process id numbers of the processes to watch.
* @see HANDLE
*/
public ProcessGroupExitWatcher(Set<Integer> processIds) {
processHandles = new ArrayList<HANDLE>(processIds.size());
//create Handles from the process ids
for (Integer pid : processIds) {
processHandles.add(Kernel32.INSTANCE.OpenProcess(Kernel32.SYNCHRONIZE, false, pid)); //synchronize must be used
}
}
public void run() {
//blocks the thread until all handles are signaled
Kernel32.INSTANCE.WaitForMultipleObjects(processHandles.size(), processHandles.toArray(new HANDLE[processHandles.size()]), true,
Kernel32.INFINITE);
for (ProcessExitListener listener : listeners) {
listener.processFinished();
}
}
/**
* Adds the listener to the list of listeners who will be notified when all
* processes have exited.
*
* @param listener
*/
public void addProcessExitListener(ProcessExitListener listener) {
listeners.add(listener);
}
/**
* Removes the listener.
*
* @param listener
*/
public void removeProcessExitListener(ProcessExitListener listener) {
listeners.remove(listener);
}
}
NOTE: when using powershell this way, the user's profile scripts are executed. This can create unexpected output which breaks the above code. Because of this, using tasklist is much more recommended.