All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.github.mike10004.xvfbmanager.XvfbManager Maven / Gradle / Ivy

The newest version!
package com.github.mike10004.xvfbmanager;

import com.github.mike10004.nativehelper.Whicher;
import com.google.common.util.concurrent.JdkFutureAdapters;
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 com.github.mike10004.xvfbmanager.Poller.PollOutcome;
import com.github.mike10004.xvfbmanager.Poller.StopReason;
import com.google.common.base.Suppliers;
import com.google.common.collect.Iterables;
import com.google.common.io.CharSource;
import com.google.common.io.Files;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.MoreExecutors;
import io.github.mike10004.subprocess.SubprocessLaunchSupport;
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nullable;
import java.io.File;
import java.io.FileDescriptor;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.file.Path;
import java.util.concurrent.Executor;
import java.util.function.Supplier;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;
import static java.util.Objects.requireNonNull;

/**
 * Class that helps manage the creation of virtual framebuffer processes.
 */
public class XvfbManager {

    private static final Logger log = LoggerFactory.getLogger(XvfbManager.class);

    private static final int SCREEN = 0;

    private final Supplier xvfbExecutableSupplier;
    private final XvfbConfig xvfbConfig;
    private final ProcessTracker processTracker;

    /**
     * Constructs a default instance of the class.
     * @see #createXvfbExecutableResolver()
     */
    public XvfbManager() {
        this(createXvfbExecutableResolver(), XvfbConfig.getDefault());
    }

    /**
     * Constructs an instance of the class with a given configuration.
     * @param xvfbConfig the configuration
     * @see #createXvfbExecutableResolver()
     */
    public XvfbManager(XvfbConfig xvfbConfig) {
        this(createXvfbExecutableResolver(), xvfbConfig);
    }

    /**
     * Constructs an instance of the class that will launch the given executable
     * with the given configuration.
     * @param xvfbExecutable pathname of the {@code Xvfb} executable
     * @param xvfbConfig virtual framebuffer configuration
     */
    public XvfbManager(File xvfbExecutable, XvfbConfig xvfbConfig) {
        this(Suppliers.ofInstance(checkNotNull(xvfbExecutable, "xvfbExecutable must be non-null; use Supplier of null instance if null is desired")), xvfbConfig);
    }

    /**
     * Constructs an instance of the class that will launch the given executable
     * with the given configuration.
     * @param xvfbExecutableSupplier supplier of the pathname of the {@code Xvfb} executable
     * @param xvfbConfig virtual framebuffer configuration
     */
    public XvfbManager(Supplier xvfbExecutableSupplier, XvfbConfig xvfbConfig) {
        this(xvfbExecutableSupplier, xvfbConfig, ShutdownHookProcessTracker.getInstance());
    }

    public XvfbManager(ProcessTracker processTracker) {
        this(createXvfbExecutableResolver(), XvfbConfig.getDefault(), processTracker);
    }

    public XvfbManager(Supplier xvfbExecutableSupplier, XvfbConfig xvfbConfig, ProcessTracker processTracker) {
        this.xvfbExecutableSupplier = checkNotNull(xvfbExecutableSupplier);
        this.xvfbConfig = checkNotNull(xvfbConfig);
        this.processTracker = requireNonNull(processTracker);
    }

    protected static String toDisplayValue(int displayNumber) {
        checkArgument(displayNumber >= 0, "displayNumber must be nonnegative");
        return String.format(":%d", displayNumber);
    }

    /**
     * Creates a supplier that returns a valid executable or null if none was found.
     * @return a supplier
     */
    protected static Supplier createXvfbExecutableResolver() {
        return new Supplier() {
            @Override
            public File get() {
                try {
                    return resolveXvfbExecutable();
                } catch (FileNotFoundException e) {
                    return null;
                }
            }

            @Override
            public String toString() {
                return "DefaultXvfbExecutableResolver";
            }
        };
    }

    protected static File resolveXvfbExecutable() throws FileNotFoundException {
        java.util.Optional file = Whicher.gnu().which("Xvfb");
        if (!file.isPresent()) {
            throw new FileNotFoundException("Xvfb executable");
        }
        return file.get();
    }

    protected Screenshooter createScreenshooter(String display, File framebufferDir) {
        return new FramebufferDirScreenshooter(framebufferDir, SCREEN, framebufferDir);
    }

    protected Sleeper createSleeper() {
        return Sleeper.DefaultSleeper.getInstance();
    }

    protected DisplayReadinessChecker createDisplayReadinessChecker(ProcessTracker tracker, String display, File framebufferDir) {
        return new DefaultDisplayReadinessChecker(tracker);
    }

    protected DefaultXvfbController createController(ProcessMonitor future, String display, File framebufferDir) {
        return new DefaultXvfbController(future, display, createDisplayReadinessChecker(processTracker, display, framebufferDir), createScreenshooter(display, framebufferDir), createSleeper());
    }

    /**
     * Starts Xvfb on the specified display using the specified executor service, writing temp
     * files to the specified directory.
     * @param displayNumber the display number
     * @param scratchDir the temp directory
     * @return the process controller
     * @throws IOException if the files and directories the process requires cannot be created or written to
     */
    public XvfbController start(int displayNumber, Path scratchDir) throws IOException {
        return doStart(displayNumber, nonDeletingExistingDirectoryProvider(scratchDir));
    }

    /**
     * Starts Xvfb on the specified display using the specified executor service. A directory for temp files
     * is created and deleted when the process is stopped.
     * @param displayNumber the display number
     * @return the process controller
     * @throws IOException if the files and directories the process requires cannot be created or written to
     */
    public XvfbController start(int displayNumber) throws IOException {
        return doStart(displayNumber, newTempDirProvider(FileUtils.getTempDirectory().toPath()));
    }

    /**
     * Starts Xvfb on a vacant display. A directory for temp files will be created and deleted
     * when the process is stopped.
     * @return the process controller
     * @throws IOException if the files and directories the process requires cannot be created or written to
     */
    public XvfbController start() throws IOException {
        return doStart(null, newTempDirProvider(FileUtils.getTempDirectory().toPath()));
    }

    /**
     * Starts Xvfb on a vacant display using the specified executor service and writing temp files
     * to the given directory.
     * @param scratchDir the temp directory
     * @return the process controller
     * @throws IOException if the files and directories the process requires cannot be created or written to
     */
    public XvfbController start(Path scratchDir) throws IOException {
        return doStart(null, nonDeletingExistingDirectoryProvider(scratchDir));
    }

    /**
     * Starts Xvfb, maybe auto-selecting a display number.
     * @param displayNumber display number, or null to auto-select
     * @param scratchDirProvider provider of scratch directory
     * @return process controller
     * @throws IOException if the files and directories the process requires cannot be created or written to
     */
    private XvfbController doStart(final @Nullable Integer displayNumber,
                                   ScratchDirProvider scratchDirProvider) throws IOException {
        String display = null;
        final boolean AUTO_DISPLAY = displayNumber == null;
        Subprocess.Builder pb;
        File xvfbExecutable = xvfbExecutableSupplier.get();
        if (xvfbExecutable == null) {
            pb = Subprocess.running("Xvfb");
        } else {
            pb = Subprocess.running(xvfbExecutable);
        }
        if (AUTO_DISPLAY) {
            pb.args("-displayfd", String.valueOf(DISPLAY_RECEIVER_FD));
        } else {
            display = toDisplayValue(displayNumber);
            pb.args(display);
        }
        Path scratchDir = scratchDirProvider.provideDirectory();
        Path framebufferDir = java.nio.file.Files.createTempDirectory(scratchDir, "xvfb-framebuffer");
        pb.args("-screen", String.valueOf(SCREEN), xvfbConfig.geometry);
        pb.args("-fbdir", framebufferDir.toAbsolutePath().toString());
        File stdoutFile = File.createTempFile("xvfb-stdout", ".txt", scratchDir.toFile());
        File stderrFile = File.createTempFile("xvfb-stderr", ".txt", scratchDir.toFile());
        Subprocess xvfbSubprocess = pb.build();
        log.trace("executing {}", xvfbSubprocess);
        SubprocessLaunchSupport launcher = xvfbSubprocess.launcher(processTracker)
                .outputFiles(stdoutFile, stderrFile);
        ProcessMonitor xvfbMonitor = launcher.launch();
        Executor callbacker = getCallbackExecutor();
        Futures.addCallback(JdkFutureAdapters.listenInPoolThread(xvfbMonitor.future()), new LoggingCallback<>("xvfb"), callbacker);
        if (scratchDirProvider.isDeleteOnStop()) {
            Futures.addCallback(JdkFutureAdapters.listenInPoolThread(xvfbMonitor.future()), new DirectoryDeletingCallback<>(scratchDir.toFile()), callbacker);
        }
        if (AUTO_DISPLAY) {
            File outputFileContainingDisplay = selectCorrespondingFile(DISPLAY_RECEIVER_FD, stdoutFile, stderrFile);
            int autoDisplayNumber = pollForDisplayNumber(Files.asCharSource(outputFileContainingDisplay, XVFB_OUTPUT_CHARSET));
            display = toDisplayValue(autoDisplayNumber);
        } else {
            checkState(display != null, "display should have been set manually from %s", displayNumber);
        }
        DefaultXvfbController controller = createController(xvfbMonitor, display, framebufferDir.toFile());
        Futures.addCallback(JdkFutureAdapters.listenInPoolThread(xvfbMonitor.future()), new AbortFlagSetter<>(controller), callbacker);
        return controller;
    }

    protected Executor getCallbackExecutor() {
        return MoreExecutors.directExecutor();
    }

    /**
     * File descriptor of the stream on which the display is printed. The program
     * prints on standard error, which in Linux is always file descriptor 2. We used
     * to be more abstract and extract the integer from {@link FileDescriptor#err},
     * but that was just showing off, really, and it used introspection implemented
     * by Gson that is scheduled to be removed in a future Java release.
     */
    private static final int DISPLAY_RECEIVER_FD = 2; // stderr

    private static final Charset XVFB_OUTPUT_CHARSET = Charset.defaultCharset(); // xvfb is platform-dependent

    protected static File selectCorrespondingFile(int fd, File stdoutFile, File stderrFile) throws IllegalArgumentException {
        switch (fd) {
            case 1:
                return stdoutFile;
            case 2:
                return stderrFile;
            default:
                throw new IllegalArgumentException("no known file corresponds to " + fd);
        }
    }

    interface ScratchDirProvider {
        Path provideDirectory() throws IOException;
        boolean isDeleteOnStop();
    }

    protected static ScratchDirProvider nonDeletingExistingDirectoryProvider(final Path directory) {
        return new ScratchDirProvider() {

            @Override
            public Path provideDirectory() {
                return directory;
            }

            @Override
            public boolean isDeleteOnStop() {
                return false;
            }
        };
    }

    protected static ScratchDirProvider newTempDirProvider(final Path parent) {
        return new ScratchDirProvider() {
            @Override
            public Path provideDirectory() throws IOException {
                return java.nio.file.Files.createTempDirectory(parent, "xvfb-manager");
            }

            @Override
            public boolean isDeleteOnStop() {
                return true;
            }
        };
    }

    private static final long AUTO_DISPLAY_POLL_INTERVAL_MS = 100;
    private static final int AUTO_DISPLAY_POLLS_MAX = 20;

    protected int pollForDisplayNumber(final CharSource cs) {
        Poller poller = new Poller() {
            @Override
            protected PollAnswer check(int pollAttemptsSoFar) {
                @Nullable String lastLine = null;
                try {
                    lastLine = Iterables.getFirst(cs.readLines().reverse(), null);
                } catch (IOException e) {
                    log.info("failed to read from {}", cs);
                }
                if (lastLine != null) {
                    lastLine = lastLine.trim();
                    if (lastLine.matches("\\d+")) {
                        int displayNumber = Integer.parseInt(lastLine);
                        return resolve(displayNumber);
                    } else {
                        log.debug("last line of xvfb output is not an integer: {}", StringUtils.abbreviate(lastLine, 128));
                    }
                }
                return continuePolling();
            }
        };
        PollOutcome pollOutcome;
        try {
            pollOutcome = poller.poll(AUTO_DISPLAY_POLL_INTERVAL_MS, AUTO_DISPLAY_POLLS_MAX);
        } catch (InterruptedException e) {
            throw new XvfbException("interrupted while polling for display number", e);
        }
        if (pollOutcome.reason == StopReason.RESOLVED) {
            assert pollOutcome.content != null : "poll resolved but outcome content is null";
            return pollOutcome.content;
        } else {
            throw new XvfbException("polling for display number (because of -displayfd option) did not behave as expected; poll terminated due to " + pollOutcome.reason);
        }

    }

    private static class AbortFlagSetter implements FutureCallback> {

        private final DefaultXvfbController xvfbController;

        private AbortFlagSetter(DefaultXvfbController xvfbController) {
            this.xvfbController = xvfbController;
        }

        @Override
        public void onSuccess(ProcessResult result) {
        }

        @Override
        public void onFailure(Throwable t) {
            xvfbController.setAbort(true);
        }
    }

    /**
     * Interface for classes that can check whether the display has reached a ready state.
     */
    public interface DisplayReadinessChecker {
        /**
         * Checks whether the display is ready.
         * @param display the display to check, e.g. ":123"
         * @return true iff the display is ready
         */
        boolean checkReadiness(String display);
    }

    static class LoggingCallback implements FutureCallback {

        private final String name;

        public LoggingCallback(String name) {
            this.name = name;
        }

        @Override
        public void onSuccess(T result) {
            if (result instanceof ProcessResult && ((ProcessResult)result).exitCode() != 0) {
                log.info("{}: {}", name, result);
            } else {
                log.debug("{}: {}", name, result);
            }
        }

        @Override
        public void onFailure(Throwable t) {
            if (t instanceof java.util.concurrent.CancellationException) {
                log.debug("{}: cancelled", name);
            } else {
                log.info("{}: {}", name, t);
            }
        }

    }

    static class DirectoryDeletingCallback implements FutureCallback> {

        private final File directory;

        DirectoryDeletingCallback(File directory) {
            this.directory = checkNotNull(directory);
        }

        @Override
        public void onSuccess(@Nullable ProcessResult result) {
            deleteDirectory();
        }

        @Override
        public void onFailure(Throwable t) {
            deleteDirectory();
        }

        protected void deleteDirectory() {
            try {
                FileUtils.deleteDirectory(directory);
            } catch (IOException e) {
                if (directory.exists()) {
                    LoggerFactory.getLogger(DirectoryDeletingCallback.class)
                            .info("failed to delete directory {}: {}", directory, e.toString());
                }
            }
        }
    }

    public ProcessTracker getProcessTracker() {
        return processTracker;
    }

    @Override
    public String toString() {
        return "XvfbManager{" +
                "xvfbExecutableSupplier=" + xvfbExecutableSupplier +
                ", xvfbConfig=" + xvfbConfig +
                ", processTracker=" + processTracker +
                '}';
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy