com.github.mike10004.xvfbmanager.DefaultXvfbController Maven / Gradle / Ivy
The newest version!
package com.github.mike10004.xvfbmanager;
import com.github.mike10004.xvfbmanager.Poller.PollOutcome;
import com.github.mike10004.xvfbmanager.Poller.StopReason;
import com.github.mike10004.xvfbmanager.TreeNode.Utils;
import com.github.mike10004.xvfbmanager.XvfbManager.DisplayReadinessChecker;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.CharMatcher;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.common.io.CharSource;
import com.google.common.io.LineProcessor;
import com.google.common.util.concurrent.ListenableFuture;
import io.github.mike10004.subprocess.ProcessMonitor;
import io.github.mike10004.subprocess.ProcessResult;
import io.github.mike10004.subprocess.ProcessTracker;
import io.github.mike10004.subprocess.Subprocess;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.Nullable;
import java.io.IOException;
import java.nio.charset.Charset;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Predicate;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;
import static java.util.Objects.requireNonNull;
/**
* Default controller implementation. This implementation relies on a {@link ListenableFuture future}
* to listen to the status of the {@code Xvfb} process. It checks for
* a given window by executing {@code xwininfo} in {@link #pollForWindow(Predicate, long, int)}.
* Most other operations are handled by the service classes provided in
* the constructor.
*/
public class DefaultXvfbController implements XvfbController {
private static final Logger log = LoggerFactory.getLogger(DefaultXvfbController.class);
private static final Iterable requiredPrograms = Iterables.concat(XWindowPoller.getRequiredPrograms());
public static Iterable getRequiredPrograms() {
return requiredPrograms;
}
/**
* Default poll interval for {@link #waitUntilReady()}}.
*/
public static final long DEFAULT_POLL_INTERVAL_MS = 250;
/**
* Default max number of polls for {@link #waitUntilReady()}}.
*/
public static final int DEFAULT_MAX_NUM_POLLS = 8;
protected static final long LOCK_FILE_CLEANUP_POLL_INTERVAL_MS = 100;
protected static final long LOCK_FILE_CLEANUP_TIMEOUT_MS = 1000;
private final ProcessMonitor, ?> xvfbMonitor;
private final String display;
private final DisplayReadinessChecker displayReadinessChecker;
private final XLockFileChecker lockFileChecker;
private final Screenshooter> screenshooter;
private final Sleeper sleeper;
private final AtomicBoolean abort;
public DefaultXvfbController(ProcessMonitor, ?> xvfbMonitor, String display,
DisplayReadinessChecker displayReadinessChecker,
Screenshooter> screenshooter, Sleeper sleeper) {
this(xvfbMonitor, display, displayReadinessChecker, screenshooter, sleeper, new PollingXLockFileChecker(LOCK_FILE_CLEANUP_POLL_INTERVAL_MS, sleeper));
}
@VisibleForTesting
protected DefaultXvfbController(ProcessMonitor, ?> xvfbMonitor, String display,
DisplayReadinessChecker displayReadinessChecker,
Screenshooter> screenshooter, Sleeper sleeper, XLockFileChecker lockFileChecker) {
this.xvfbMonitor = requireNonNull(xvfbMonitor);
this.display = checkNotNull(display);
this.displayReadinessChecker = checkNotNull(displayReadinessChecker);
this.screenshooter = checkNotNull(screenshooter);
this.sleeper = checkNotNull(sleeper);
abort = new AtomicBoolean(false);
this.lockFileChecker = checkNotNull(lockFileChecker);
}
void setAbort(@SuppressWarnings("SameParameterValue") boolean abort) {
this.abort.getAndSet(abort);
}
public void waitUntilReady() throws InterruptedException {
waitUntilReady(DEFAULT_POLL_INTERVAL_MS, DEFAULT_MAX_NUM_POLLS);
}
@Override
public String getDisplay() {
return display;
}
@Override
public Map configureEnvironment(Map environment) {
environment.put(ENV_DISPLAY, display);
return environment;
}
@Override
public Map newEnvironment() {
return configureEnvironment(createEmptyMutableMap());
}
protected Map createEmptyMutableMap() {
return new HashMap<>();
}
private boolean checkAbort() {
return abort.get();
}
private String formatXvfbExitedMessage(ProcessResult, ?> result) {
String info = null;
if (result.exitCode() != 0) {
info = result.toString();
}
return "xvfb already exited with code " + result.exitCode() + (info == null ? "" : ": " + info);
}
private boolean isXvfbAlreadyDone() {
if (xvfbMonitor.future().isDone()) {
try {
ProcessResult, ?> result = xvfbMonitor.await();
String message = formatXvfbExitedMessage(result);
log.error(message);
} catch (InterruptedException e) {
throw new IllegalStateException("ProcessMonitor.await() should return immediately if Future.isDone() is true", e);
}
return true;
} else {
return false;
}
}
public void waitUntilReady(long pollIntervalMs, int maxNumPolls) throws InterruptedException {
PollOutcome pollResult = new Poller(sleeper) {
@Override
protected PollAnswer check(int pollAttemptsSoFar) {
if (isXvfbAlreadyDone()) {
return abortPolling();
}
if (checkAbort()) {
return abortPolling();
}
boolean ready = displayReadinessChecker.checkReadiness(display);
return ready ? resolve(true) : continuePolling();
}
}.poll(pollIntervalMs, maxNumPolls);
boolean displayReady = (pollResult.reason == StopReason.RESOLVED) && pollResult.content != null && pollResult.content;
if (!displayReady) {
throw new XvfbException("display never became ready: " + pollResult);
}
}
private static final int SIGTERM_TIMEOUT_MILLIS = 500;
@Override
public void stop() {
if (xvfbMonitor.process().isAlive()) {
xvfbMonitor.destructor().sendTermSignal().await(SIGTERM_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS).kill();
waitForXLockFileCleanup();
}
}
protected interface XLockFileChecker {
void waitForCleanup(String display, long timeoutMs) throws LockFileCheckingException;
@SuppressWarnings("unused")
class LockFileCheckingException extends XvfbException {
public LockFileCheckingException() {
}
public LockFileCheckingException(String message) {
super(message);
}
public LockFileCheckingException(String message, Throwable cause) {
super(message, cause);
}
public LockFileCheckingException(Throwable cause) {
super(cause);
}
}
@SuppressWarnings("unused")
class LockFileCleanupTimeoutException extends LockFileCheckingException {
public LockFileCleanupTimeoutException(String message) {
super(message);
}
}
}
protected void waitForXLockFileCleanup() {
lockFileChecker.waitForCleanup(display, LOCK_FILE_CLEANUP_TIMEOUT_MS);
}
@Override
public Screenshooter> getScreenshooter() throws XvfbException {
return screenshooter;
}
/**
* Invokes {@link #stop()}.
*/
@Override
public void close() {
stop();
}
@Override
public Optional> pollForWindow(java.util.function.Predicate windowFinder, long intervalMs, int maxPollAttempts) throws InterruptedException {
XWindowPoller poller = new XWindowPoller(xvfbMonitor.tracker(), display, windowFinder);
PollOutcome> pollResult = poller.poll(intervalMs, maxPollAttempts);
return Optional.ofNullable(pollResult.content);
}
private static class XWindowPoller extends Poller> {
private static final String PROG_XWININFO = "xwininfo";
private static final ImmutableSet requiredPrograms = ImmutableSet.of(PROG_XWININFO);
public static Iterable getRequiredPrograms() {
return requiredPrograms;
}
private final String display;
private final java.util.function.Predicate evaluator;
private final ProcessTracker processTracker;
public XWindowPoller(ProcessTracker processTracker, String display, java.util.function.Predicate evaluator) {
super();
this.processTracker = requireNonNull(processTracker);
this.display = checkNotNull(display);
this.evaluator = checkNotNull(evaluator);
}
private static final int XWININFO_SIGTERM_TIMEOUT_MILLIS = 1000;
@Override
protected PollAnswer> check(int pollAttemptsSoFar) {
ProcessMonitor xwininfoMonitor = Subprocess.running(PROG_XWININFO)
.args("-display", display)
.args("-root", "-tree")
.build()
.launcher(processTracker)
.outputStrings(Charset.defaultCharset()) // presumably writes in system charset
.launch();
ProcessResult result = null;
try {
result = xwininfoMonitor.await();
} catch (InterruptedException e) {
log.error("interrupted while waiting for xwininfo result", e);
xwininfoMonitor.destructor().sendTermSignal()
.await(XWININFO_SIGTERM_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)
.kill();
}
if (result != null && result.exitCode() == 0) {
try {
TreeNode root = CharSource.wrap(result.content().stdout()).readLines(new XwininfoXwindowParser());
@Nullable XWindow evaluatedNode = null;
for (TreeNode node : root.breadthFirstTraversal()) {
if (evaluator.test(node.getLabel())) {
evaluatedNode = node.getLabel();
break;
}
}
final @Nullable XWindow match = evaluatedNode;
if (match != null) {
//noinspection StaticPseudoFunctionalStyleMethod
TreeNode targetWindowNode = Iterables.find(Utils.traverser().breadthFirst(root), input -> match == checkNotNull(input).getLabel());
return resolve(targetWindowNode);
} else {
return continuePolling();
}
} catch (IOException e) {
throw new XvfbException(e);
}
} else {
return continuePolling();
}
}
}
static class XwininfoXwindowParser extends XwininfoParser {
static final Pattern linePattern = Pattern.compile("\\s*(0x[a-f0-9]+)\\s((?:\\Q(has no name)\\E)|(?:\".+\")):", Pattern.CASE_INSENSITIVE);
@Override
protected XWindow parseWindow(String line, boolean root) {
String id, title = null;
if (root) {
Matcher m = Pattern.compile("\\b0x[a-f0-9]+\\b", Pattern.CASE_INSENSITIVE).matcher(line);
checkState(m.find(), "no id found on line %s", line);
id = m.group(0);
} else {
Matcher m = linePattern.matcher(line);
if (!m.find()) {
throw new IllegalArgumentException("line does not match pattern: " + line);
}
id = m.group(1);
title = m.group(2);
if ("(has no name)".equals(title)) {
title = null;
} else {
title = CharMatcher.is('"').trimFrom(title);
}
}
return new XWindow(id, title, line);
}
}
static abstract class XwininfoParser implements LineProcessor> {
static int COMMON_INDENT = 2;
static int INDENT_PER_LEVEL = 3;
private TreeNode root = null;
private TreeNode prev = null;
private CharMatcher ws = CharMatcher.whitespace();
private int previousIndent = 0;
protected boolean skip(String line, String explanation) {
return true;
}
protected abstract E parseWindow(String line, boolean root);
@Override
public boolean processLine(@SuppressWarnings("NullableProblems") String line) {
if (line.trim().isEmpty()) {
return skip(line, "empty");
}
if (line.startsWith("xwininfo:")) {
return skip(line, "header");
}
if (line.startsWith(" Parent window id: 0x0 (none)")) {
return skip(line, "extraneous");
}
if (line.matches("\\s*\\d+ child(?:ren)?[:.]\\s*")) { // "1 child:" or "6 children:" or "0 children."
return skip(line, "childcount");
}
TreeNode current = new ListTreeNode<>(parseWindow(line, root == null));
if (root == null) {
root = current;
prev = root;
previousIndent = measureIndent(line);
return foundRoot(root);
}
int indent = measureIndent(line);
if (indent == previousIndent) {
TreeNode parent = checkNotNull(prev.getParent(), "thought prev would not be root");
parent.addChild(current);
foundSibling(indent, current);
} else {
if (indent > previousIndent) { // we are at prev's child
prev.addChild(current);
foundChild(indent, current);
} else { // back up as many levels as indent indicates
int previousLevels = (previousIndent - COMMON_INDENT) / INDENT_PER_LEVEL;
int levels = (indent - COMMON_INDENT) / INDENT_PER_LEVEL;
assert previousLevels > levels;
TreeNode parent = prev.getParent();
for (int i = 0; i < (previousLevels - levels); i++) {
parent = parent.getParent();
}
parent.addChild(current);
foundAncestor(indent, current);
}
}
previousIndent = indent;
prev = current;
return true;
}
protected void foundAncestor(int indent, TreeNode node) {
}
protected void foundSibling(int indent, TreeNode node) {
}
protected void foundChild(int indent, TreeNode node) {
}
protected boolean foundRoot(TreeNode root) {
return true;
}
private int measureIndent(CharSequence seq) {
for (int i = 0; i < seq.length(); i++) {
char ch = seq.charAt(i);
if (!ws.matches(ch)) {
return i;
}
}
return 0;
}
@Override
public TreeNode getResult() {
return root;
}
@VisibleForTesting
TreeNode parse(CharSource text) throws IOException {
return text.readLines(this);
}
}
}
© 2015 - 2024 Weber Informatics LLC | Privacy Policy