net.pms.service.ProcessManager.java Source code

Java tutorial

Introduction

Here is the source code for net.pms.service.ProcessManager.java

Source

/*
 * Digital Media Server, for streaming digital media to UPnP AV or DLNA
 * compatible devices based on PS3 Media Server and Universal Media Server.
 * Copyright (C) 2016 Digital Media Server developers.
 *
 * This program is free software: you can redistribute it and/or modify it under
 * the terms of the GNU General Public License as published by the Free Software
 * Foundation, either version 3 of the License, or (at your option) any later
 * version.
 *
 * This program is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
 * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
 * details.
 *
 * You should have received a copy of the GNU General Public License along with
 * this program. If not, see http://www.gnu.org/licenses/.
 */
package net.pms.service;

import static org.apache.commons.lang3.StringUtils.isBlank;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.Locale;
import java.util.Map.Entry;
import java.util.TreeMap;
import java.util.concurrent.TimeUnit;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.annotation.concurrent.GuardedBy;
import javax.annotation.concurrent.Immutable;
import javax.annotation.concurrent.NotThreadSafe;
import javax.annotation.concurrent.ThreadSafe;
import org.apache.commons.io.IOUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.sun.jna.Memory;
import com.sun.jna.Platform;
import com.sun.jna.Pointer;
import com.sun.jna.platform.win32.Kernel32;
import com.sun.jna.platform.win32.User32;
import com.sun.jna.platform.win32.WinDef.HWND;
import com.sun.jna.platform.win32.WinDef.LPARAM;
import com.sun.jna.platform.win32.WinDef.WPARAM;
import com.sun.jna.platform.win32.WinNT.HANDLE;
import com.sun.jna.platform.win32.WinUser.WNDENUMPROC;
import com.sun.jna.ptr.IntByReference;
import net.pms.configuration.PlatformProgramPaths;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;

/**
 * This class is used to manage the shutdown of external processes if they run
 * longer than their expected run time or hangs. It uses its own thread and
 * internal scheduling to shut down managed processes once their run time
 * expires. A graceful shutdown is initially escalating to less graceful methods
 * until successful. If nothing works, the shutdown is left to the JVM with
 * {@link Process#destroy()} with its known shortcomings.
 *
 * @author Nadahar
 */
@ThreadSafe
public class ProcessManager {

    private static final Logger LOGGER = LoggerFactory.getLogger(ProcessManager.class);

    /** Windows API {@code CTRL_C_EVENT} */
    public static final int CTRL_C_EVENT = 0;

    /** Windows API {@code CTRL_BREAK_EVENT} */
    public static final int CTRL_BREAK_EVENT = 1;

    /** The queue of incoming {@link ProcessTicket}s. */
    protected final LinkedList<ProcessTicket> incoming = new LinkedList<>();

    /** The {@link ProcessTerminator} thread */
    @GuardedBy("incoming")
    protected ProcessTerminator terminator;

    /**
     * Creates a new instance and starts the {@link ProcessTerminator} thread.
     */
    public ProcessManager() {
        start();
    }

    /**
     * Starts the {@link ProcessManager}. This will be called automatically in
     * the constructor, and need only be called if {@link #stop()} has
     * previously been called.
     */
    public void start() {
        synchronized (incoming) {
            if (terminator == null || !terminator.isAlive()) {
                LOGGER.debug("Starting ProcessManager");
                terminator = new ProcessTerminator(this);
                terminator.start();
            } else if (LOGGER.isDebugEnabled()) {
                LOGGER.warn("ProcessManager is already running, start attempt failed");
            }
        }
    }

    /**
     * Stops the {@link ProcessManager}. This will cause its process terminator
     * thread to terminate all managed processes and stop.
     */
    public void stop() {
        ProcessTerminator currentTerminator;
        synchronized (incoming) {
            currentTerminator = terminator;
            terminator = null;
        }
        if (currentTerminator != null) {
            LOGGER.debug("Stopping ProcessManager");
            currentTerminator.interrupt();
            try {
                currentTerminator.join();
            } catch (InterruptedException e) {
                LOGGER.debug(
                        "ProcessManager was interrupted while waiting for the process terminator to terminate");
            }
        }
    }

    /**
     * Detaches the {@link ProcessTerminator} thread unless a new has already
     * been set.
     *
     * @param terminator the {@link ProcessTerminator} instance to clear.
     */
    protected void clearWorker(ProcessTerminator terminator) {
        synchronized (incoming) {
            if (this.terminator == terminator) {
                this.terminator = null;
            }
        }
    }

    /**
     * Adds a {@link Process} to be managed by this {@link ProcessManager}.
     *
     * @param process the {@link Process} to manage.
     * @param processName the name of the process used for
     *            logging/identification.
     * @param timeoutMS the timeout for this {@link Process} in milliseconds.
     *            When this time has expired, the process will be shut down if
     *            it isn't already finished. If it's already finished, it will
     *            simply be removed from the schedule.
     * @param terminateTimeoutMS the timeout for shutdown attempts in
     *            milliseconds. This timeout is used for each shutdown attempt
     *            before escalating to the next level. Any value below 100
     *            milliseconds will be set to 100 milliseconds.
     */
    public void addProcess(@Nonnull Process process, @Nonnull String processName, long timeoutMS,
            long terminateTimeoutMS) {
        addTicket(new ProcessTicket(process, processName, ProcessTicketAction.ADD, timeoutMS, terminateTimeoutMS));
    }

    /**
     * Adds a {@link Process} to be managed by this {@link ProcessManager}.
     *
     * @param process the {@link Process} to manage.
     * @param processName the name of the process used for
     *            logging/identification.
     * @param timeout the timeout for this {@link Process} in {@code timeUnit}.
     *            When this time has expired, the process will be shut down if
     *            it isn't already finished. If it's already finished, it will
     *            simply be removed from the schedule.
     * @param timeUnit the {@link TimeUnit} for {@code timeout}.
     * @param terminateTimeoutMS the timeout for shutdown attempts in
     *            milliseconds. This timeout is used for each shutdown attempt
     *            before escalating to the next level. Any value below 100
     *            milliseconds will be set to 100 milliseconds.
     */
    public void addProcess(@Nonnull Process process, @Nonnull String processName, long timeout,
            @Nonnull TimeUnit timeUnit, long terminateTimeoutMS) {
        addTicket(new ProcessTicket(process, processName, ProcessTicketAction.ADD, timeUnit.toMillis(timeout),
                terminateTimeoutMS));
    }

    /**
     * Reschedule a managed process for immediate shutdown. If {@code process}
     * isn't found among the managed processes no action is taken.
     *
     * @param process the {@link Process} to shutdown.
     * @param processName the name of the process used for
     *            logging/identification.
     * @param terminateTimeoutMS the timeout for shutdown attempts in
     *            milliseconds. This timeout is used for each shutdown attempt
     *            before escalating to the next level. Any value below 100
     *            milliseconds will be set to 100 milliseconds.
     */
    public void shutdownProcess(@Nonnull Process process, @Nonnull String processName, long terminateTimeoutMS) {
        addTicket(new ProcessTicket(process, processName, ProcessTicketAction.SHUTDOWN, 0, terminateTimeoutMS));
    }

    /**
     * Reschedule a managed process for immediate shutdown. If {@code process}
     * isn't found among the managed processes no action is taken.
     *
     * @param process the {@link Process} to shutdown.
     * @param processName the name of the process used for
     *            logging/identification.
     */
    public void shutdownProcess(@Nonnull Process process, @Nonnull String processName) {
        addTicket(new ProcessTicket(process, processName, ProcessTicketAction.SHUTDOWN, 0, 0));
    }

    /**
     * Removes a {@link Process} from management by this {@link ProcessManager}.
     * This will cause the reference to the {@link Process} to be released
     * allowing for earlier GC or prevent it from being shutdown at timeout.
     *
     * @param process the {@link Process} to remove from management.
     * @param processName the name of the process used for
     *            logging/identification.
     */
    public void removeProcess(@Nonnull Process process, @Nonnull String processName) {
        addTicket(new ProcessTicket(process, processName, ProcessTicketAction.REMOVE, 0, 0));
    }

    /**
     * Adds a {@link ProcessTicket} to the internal queue.
     *
     * @param ticket the {@link ProcessTicket} to add.
     */
    protected void addTicket(@Nonnull ProcessTicket ticket) {
        if (ticket == null) {
            throw new IllegalArgumentException("ticket cannot be null");
        }
        synchronized (incoming) {
            incoming.add(ticket);
            incoming.notify();
            if (terminator == null || !terminator.isAlive()) {
                LOGGER.warn(
                        "ProcessManager added the following ticket while no ProcessTerminator is processing tickets: {}",
                        ticket);
            }
        }
    }

    /**
     * Checks if the process is still alive using reflection if possible.
     *
     * @param process the {@link Process} to check.
     * @return {@code true} if the process is still alive, {@code false}
     *         otherwise.
     */
    @SuppressFBWarnings("REC_CATCH_EXCEPTION")
    public static boolean isProcessIsAlive(@Nullable Process process) {
        if (process == null) {
            return false;
        }
        // XXX replace with Process.isAlive() in Java 8
        try {
            Field field;
            field = process.getClass().getDeclaredField("handle");
            field.setAccessible(true);
            long handle = (long) field.get(process);
            field = process.getClass().getDeclaredField("STILL_ACTIVE");
            field.setAccessible(true);
            int stillActive = (int) field.get(process);
            Method method;
            method = process.getClass().getDeclaredMethod("getExitCodeProcess", long.class);
            method.setAccessible(true);
            int exitCode = (int) method.invoke(process, handle);
            return exitCode == stillActive;
        } catch (Exception e) {
            // Reflection failed, use the backup solution
        }
        try {
            process.exitValue();
            return false;
        } catch (IllegalThreadStateException e) {
            return true;
        }
    }

    /**
     * Retrieves the process ID (PID) for the specified {@link Process}.
     *
     * @param process the {@link Process} for whose PID to retrieve.
     * @return The PID or zero if the PID couldn't be retrieved.
     */
    public static int getProcessId(@Nullable Process process) {
        if (process == null) {
            return 0;
        }
        try {
            Field field;
            if (Platform.isWindows()) {
                field = process.getClass().getDeclaredField("handle");
                field.setAccessible(true);
                int pid = Kernel32.INSTANCE.GetProcessId(new HANDLE(new Pointer(field.getLong(process))));
                if (pid == 0 && LOGGER.isDebugEnabled()) {
                    int lastError = Kernel32.INSTANCE.GetLastError();
                    LOGGER.debug("KERNEL32.getProcessId() failed with error {}", lastError);
                }
                return pid;
            }
            field = process.getClass().getDeclaredField("pid");
            field.setAccessible(true);
            return field.getInt(process);
        } catch (Exception e) {
            LOGGER.warn("Failed to get process id for process \"{}\": {}", process, e.getMessage());
            LOGGER.trace("", e);
            return 0;
        }
    }

    /**
     * The process terminator implementation.
     *
     * @author Nadahar
     */
    protected static class ProcessTerminator extends Thread {

        private static final Logger LOGGER = LoggerFactory.getLogger(ProcessTerminator.class);

        /** The {@link TreeMap} of {@link Process}es scheduled for shutdown */
        protected final TreeMap<Long, ProcessInfo> processes = new TreeMap<>();

        /** The {@link ProcessManager} "owning" this {@link ProcessTerminator} */
        protected final ProcessManager owner;

        /**
         * Creates a new instance.
         *
         * @param owner the {@link ProcessManager} controlling this terminator thread.
         */
        public ProcessTerminator(@Nonnull ProcessManager owner) {
            super("Process Terminator");
            if (owner == null) {
                throw new IllegalArgumentException("owner cannot be null");
            }
            this.owner = owner;
            this.setDaemon(true);
        }

        /**
         * Gobbles (consumes) an {@link InputStream}.
         *
         * @param is the {@link InputStream} to gobble.
         */
        @SuppressWarnings("checkstyle:EmptyBlock")
        protected void gobbleStream(InputStream is) {
            if (is == null) {
                return;
            }
            byte[] gobbler = new byte[1024];
            try {
                while (is.read(gobbler) != -1) {
                }
            } catch (IOException e) {
                LOGGER.error("Gobbling of {} failed with: {}", is.getClass(), e.getMessage());
                LOGGER.trace("", e);
            }
        }

        /**
         * Sends {@code WM_CLOSE} to the specified Windows {@link Process}.
         *
         * @param processInfo the {@link ProcessInfo} referencing the
         *            {@link Process} to send to.
         * @return {@code true} if {@code WM_CLOSE} was sent, {@code false}
         *         otherwise.
         */
        protected boolean stopWindowsProcessWMClosed(@Nonnull ProcessInfo processInfo) {
            if (LOGGER.isTraceEnabled()) {
                LOGGER.trace("Attempting to stop timed out process \"{}\" ({}) with WM_CLOSE",
                        processInfo.getName(), processInfo.getPID());
            }
            HANDLE hProc = Kernel32.INSTANCE.OpenProcess(Kernel32.SYNCHRONIZE | Kernel32.PROCESS_TERMINATE, false,
                    processInfo.getPID());
            if (hProc == null) {
                if (LOGGER.isTraceEnabled()) {
                    LOGGER.trace("Failed to get Windows handle for process \"{}\" ({}) during WM_CLOSE",
                            processInfo.getName(), processInfo.getPID());
                }
                return false;
            }
            final Memory posted = new Memory(1);
            posted.setByte(0, (byte) 0);
            Memory dwPID = new Memory(4);
            dwPID.setInt(0, processInfo.getPID());
            User32.INSTANCE.EnumWindows(new WNDENUMPROC() {

                @Override
                public boolean callback(HWND hWnd, Pointer data) {
                    IntByReference dwID = new IntByReference();
                    User32.INSTANCE.GetWindowThreadProcessId(hWnd, dwID);

                    if (dwID.getValue() == data.getInt(0)) {
                        User32.INSTANCE.PostMessage(hWnd, User32.WM_CLOSE, new WPARAM(0), new LPARAM(0));
                        posted.setByte(0, (byte) 1);
                    }
                    return true;
                }
            }, dwPID);
            Kernel32.INSTANCE.CloseHandle(hProc);
            if (LOGGER.isTraceEnabled()) {
                if (posted.getByte(0) > 0) {
                    LOGGER.trace("WM_CLOSE sent to process \"{}\" ({}) with PostMessage", processInfo.getName(),
                            processInfo.getPID());
                } else {
                    LOGGER.trace("Can't find any Windows belonging to process \"{}\" ({}), unable to send WM_CLOSE",
                            processInfo.getName(), processInfo.getPID());
                }
            }
            return posted.getByte(0) > 0;
        }

        /**
         * Performs {@code TerminateProcess} on the specified Windows
         * {@link Process}.
         *
         * @param processInfo the {@link ProcessInfo} referencing the
         *            {@link Process} to terminate.
         * @return {@code true} if {@code TerminateProcess} was executed,
         *         {@code false} otherwise.
         */
        protected boolean stopWindowsProcessTerminateProcess(@Nonnull ProcessInfo processInfo) {
            if (LOGGER.isTraceEnabled()) {
                LOGGER.trace("Attempting to stop timed out process \"{}\" ({}) with TerminateProcess",
                        processInfo.getName(), processInfo.getPID());
            }
            HANDLE hProc = Kernel32.INSTANCE.OpenProcess(Kernel32.PROCESS_TERMINATE, false, processInfo.getPID());
            if (hProc == null) {
                if (LOGGER.isTraceEnabled()) {
                    LOGGER.trace("Failed to get Windows handle for process \"{}\" ({}) during TerminateProcess",
                            processInfo.getName(), processInfo.getPID());
                }
                return false;
            }
            boolean result = Kernel32.INSTANCE.TerminateProcess(hProc, 1);
            Kernel32.INSTANCE.CloseHandle(hProc);
            if (LOGGER.isTraceEnabled()) {
                if (result) {
                    LOGGER.trace("TerminateProcess performed for process \"{}\" ({})", processInfo.getName(),
                            processInfo.getPID());
                } else {
                    LOGGER.trace("TerminateProcess failed for process \"{}\" ({})", processInfo.getName(),
                            processInfo.getPID());
                }
            }
            return result;
        }

        /**
         * Sends a {@code CtrlEvent} to the specified Windows {@link Process}.
         *
         * @param processInfo the {@link ProcessInfo} referencing the
         *            {@link Process} to send to.
         * @param ctrlEvent the {@code CtrlEvent} to send. Only
         *            {@link ProcessManager#CTRL_BREAK_EVENT} and
         *            {@link ProcessManager#CTRL_C_EVENT} are supported.
         * @return {@code true} if a {@code CtrlEvent} was sent, {@code false}
         *         otherwise.
         * @throws InterruptedException If the {@link Thread} was interrupted
         *             during the operation.
         */
        protected boolean stopWindowsProcessCtrlEvent(@Nonnull ProcessInfo processInfo, int ctrlEvent)
                throws InterruptedException {
            if (PlatformProgramPaths.get().getCtrlSender() == null) {
                return false;
            }
            if (LOGGER.isTraceEnabled()) {
                LOGGER.trace("Attempting to stop timed out process \"{}\" ({}) with CtrlSender",
                        processInfo.getName(), processInfo.getPID());
            }
            ProcessBuilder processBuilder = new ProcessBuilder(
                    PlatformProgramPaths.get().getCtrlSender().toString(), Integer.toString(processInfo.getPID()),
                    Integer.toString(ctrlEvent));
            processBuilder.redirectErrorStream(true);
            try {
                Process process = processBuilder.start();
                gobbleStream(process.getInputStream());
                int exitCode = process.waitFor();
                if (exitCode != 0) {
                    if (exitCode == 1) {
                        LOGGER.trace("CtrlSender could not attach to PID {} for process \"{}\"",
                                processInfo.getPID(), processInfo.getName());
                    } else {
                        LOGGER.warn("An internal error caused CtrlSender to exit with code {}", exitCode);
                    }
                    return false;
                }
                if (LOGGER.isTraceEnabled()) {
                    LOGGER.trace("Ctrl + {} sent to process \"{}\" ({}) with CtrlSender",
                            ctrlEvent == CTRL_C_EVENT ? "C" : ctrlEvent == CTRL_BREAK_EVENT ? "BREAK" : "?",
                            processInfo.getName(), processInfo.getPID());
                }
                return true;
            } catch (IOException e) {
                LOGGER.error("CtrlSender for process \"{}\" ({}) failed with: {}", processInfo.getName(),
                        processInfo.getPID(), e.getMessage());
                LOGGER.trace("", e);
                return false;
            }
        }

        /**
         * Performs a {@code TaskKill} on the specified Windows {@link Process}.
         *
         * @param processInfo the {@link ProcessInfo} referencing the
         *            {@link Process} to {@code TaskKill}.
         * @return {@code true} if a {@code TaskKill} was executed,
         *         {@code false} otherwise.
         * @throws InterruptedException If the {@link Thread} was interrupted
         *             during the operation.
         */
        protected boolean stopWindowsProcessTaskKill(@Nonnull ProcessInfo processInfo) throws InterruptedException {
            if (PlatformProgramPaths.get().getTaskKill() == null) {
                return false;
            }
            if (LOGGER.isTraceEnabled()) {
                LOGGER.trace("Attempting to stop timed out process \"{}\" ({}) with TaskKill",
                        processInfo.getName(), processInfo.getPID());
            }
            ProcessBuilder processBuilder = new ProcessBuilder(PlatformProgramPaths.get().getTaskKill().toString(),
                    "/PID", Integer.toString(processInfo.getPID()));
            processBuilder.redirectErrorStream(true);
            try {
                Process process = processBuilder.start();
                gobbleStream(process.getInputStream());
                int exitCode = process.waitFor();
                if (exitCode != 0) {
                    LOGGER.debug("TaskKill failed for process \"{}\" ({}) with exit code {}", processInfo.getName(),
                            processInfo.getPID(), exitCode);
                    return false;
                }
                if (LOGGER.isTraceEnabled()) {
                    LOGGER.trace("Taskkill performed for process \"{}\" ({})", processInfo.getName(),
                            processInfo.getPID());
                }
                return true;
            } catch (IOException e) {
                LOGGER.error("TaskkKill for process \"{}\" ({}) failed with: {}", processInfo.getName(),
                        processInfo.getPID(), e.getMessage());
                LOGGER.trace("", e);
                return false;
            }
        }

        /**
         * Sends a {@code POSIX signal} to the specified POSIX {@link Process}.
         *
         * @param processInfo the {@link ProcessInfo} referencing the
         *            {@link Process} to send to.
         * @param signal the {@link POSIXSignal} to send.
         * @return {@code true} if the signal was sent, {@code false} otherwise.
         * @throws InterruptedException If the {@link Thread} was interrupted
         *             during the operation.
         */
        protected boolean sendPOSIXSignal(@Nonnull ProcessInfo processInfo, @Nullable POSIXSignal signal)
                throws InterruptedException {
            if (signal == null) {
                signal = POSIXSignal.SIGTERM;
            }
            if (LOGGER.isTraceEnabled()) {
                LOGGER.trace("Attempting to send {} to timed out process \"{}\" ({})", signal,
                        processInfo.getName(), processInfo.getPID());
            }
            ProcessBuilder processBuilder = new ProcessBuilder("kill", "-" + signal.getValue(),
                    Integer.toString(processInfo.getPID()));
            processBuilder.redirectErrorStream(true);
            try {
                Process process = processBuilder.start();
                gobbleStream(process.getInputStream());
                int exitCode = process.waitFor();
                if (exitCode != 0) {
                    LOGGER.debug("kill -{} failed for process \"{}\" ({}) with exit code {}", signal.getValue(),
                            processInfo.getName(), processInfo.getPID(), exitCode);
                    return false;
                }
                if (LOGGER.isTraceEnabled()) {
                    LOGGER.trace("{} sent to process \"{}\" ({})", signal, processInfo.getName(),
                            processInfo.getPID());
                }
                return true;
            } catch (IOException e) {
                LOGGER.error("kill -{} for process \"{}\" ({}) failed with: {}", signal.getValue(),
                        processInfo.getName(), processInfo.getPID(), e.getMessage());
                LOGGER.trace("", e);
                return false;
            }
        }

        /**
         * Attempts to stop a Windows {@link Process} using various methods
         * depending on the current {@link ProcessState}.
         *
         * @param processInfo the {@link ProcessInfo} referencing the Windows
         *            {@link Process} to stop.
         * @throws InterruptedException If the {@link Thread} was interrupted
         *             during the operation.
         */
        protected void stopWindowsProcess(@Nonnull ProcessInfo processInfo) throws InterruptedException {
            if (processInfo.getState() == ProcessState.RUNNING) {
                if (stopWindowsProcessWMClosed(processInfo)) {
                    processInfo.setState(ProcessState.WM_CLOSED);
                    processes.put(getSchedule(processInfo.getTerminateTimeoutMS()), processInfo);
                    return;
                }
                if (stopWindowsProcessCtrlEvent(processInfo, CTRL_C_EVENT)) {
                    processInfo.setState(ProcessState.CTRL_C);
                    processes.put(getSchedule(processInfo.getTerminateTimeoutMS()), processInfo);
                    return;
                }
            }
            if ((processInfo.getState() == ProcessState.RUNNING || processInfo.getState() == ProcessState.WM_CLOSED
                    || processInfo.getState() == ProcessState.CTRL_C)
                    && PlatformProgramPaths.get().getTaskKill() != null) {
                if (stopWindowsProcessTaskKill(processInfo)) {
                    processInfo.setState(ProcessState.TASKKILL);
                    processes.put(getSchedule(processInfo.getTerminateTimeoutMS()), processInfo);
                    return;
                }
            }
            if (stopWindowsProcessTerminateProcess(processInfo)) {
                processInfo.setState(ProcessState.TERMINATEPROCESS);
                processes.put(getSchedule(Math.max(500, processInfo.getTerminateTimeoutMS())), processInfo);
                return;
            }
            LOGGER.warn(
                    "All previous attempts to terminate process \"{}\" ({}) has failed, leaving it to the JVM and hoping for the best",
                    processInfo.getName(), processInfo.getPID());
            destroyProcess(processInfo.getProcess());
        }

        /**
         * Attempts to stop a POSIX {@link Process} using various methods
         * depending on the current {@link ProcessState}.
         *
         * @param processInfo the {@link ProcessInfo} referencing the POSIX
         *            {@link Process} to stop.
         * @throws InterruptedException If the {@link Thread} was interrupted
         *             during the operation.
         */
        protected void stopPOSIXProcess(@Nonnull ProcessInfo processInfo) throws InterruptedException {
            if (processInfo.getState() == ProcessState.RUNNING) {
                if (sendPOSIXSignal(processInfo, POSIXSignal.SIGTERM)) {
                    processInfo.setState(ProcessState.SIGTERM);
                    processes.put(getSchedule(processInfo.getTerminateTimeoutMS()), processInfo);
                    return;
                }
            }
            if (processInfo.getState() == ProcessState.RUNNING || processInfo.getState() == ProcessState.SIGTERM) {
                String nameLower = processInfo.getName().toLowerCase(Locale.ROOT);
                if ((nameLower.contains("mencoder") || nameLower.contains("mplayer"))
                        && sendPOSIXSignal(processInfo, POSIXSignal.SIGALRM)) {
                    //Special case for MPlayer/MEncoder which responds to SIGALRM
                    processInfo.setState(ProcessState.SIGALRM);
                    processes.put(getSchedule(processInfo.getTerminateTimeoutMS()), processInfo);
                    return;
                }
            }
            if ((processInfo.getState() == ProcessState.RUNNING || processInfo.getState() == ProcessState.SIGTERM
                    || processInfo.getState() == ProcessState.SIGALRM)
                    && sendPOSIXSignal(processInfo, POSIXSignal.SIGKILL)) {
                processInfo.setState(ProcessState.SIGKILL);
                processes.put(getSchedule(Math.max(500, processInfo.getTerminateTimeoutMS())), processInfo);
                return;
            }
            LOGGER.warn(
                    "All previous attempts to terminate process \"{}\" ({}) has failed, leaving it to the JVM and hoping for the best",
                    processInfo.getName(), processInfo.getPID());
            destroyProcess(processInfo.getProcess());
        }

        /**
         * Attempts to stop a {@link Process} by delegating to the platform
         * dependent method unless the process is already stopped.
         *
         * @param processInfo the {@link ProcessInfo} referencing the
         *            {@link Process} to stop.
         * @throws InterruptedException If the {@link Thread} was interrupted
         *             during the operation.
         */
        protected void stopProcess(@Nullable ProcessInfo processInfo) throws InterruptedException {
            if (processInfo == null) {
                return;
            }
            if (isProcessIsAlive(processInfo.getProcess())) {
                if (LOGGER.isDebugEnabled()) {
                    LOGGER.debug("Trying to terminate process \"{}\" ({}) since its allowed run time has expired",
                            processInfo.getName(), processInfo.getPID());
                }
                if (Platform.isWindows()) {
                    stopWindowsProcess(processInfo);
                } else {
                    stopPOSIXProcess(processInfo);
                }
            } else {
                if (LOGGER.isDebugEnabled()) {
                    LOGGER.debug("Successfully terminated process \"{}\" ({})", processInfo.getName(),
                            processInfo.getPID());
                }
                destroyProcess(processInfo.process);
            }
        }

        /**
         * Destroys a {@link Process} after closing all the attached streams.
         *
         * @param process the {@link Process} to destroy.
         */
        protected void destroyProcess(@Nullable Process process) {
            if (process == null) {
                return;
            }
            IOUtils.closeQuietly(process.getInputStream());
            IOUtils.closeQuietly(process.getErrorStream());
            IOUtils.closeQuietly(process.getOutputStream());
            process.destroy();
        }

        /**
         * Gets the next available schedule time in nanoseconds that is at least
         * {@code delayMS} milliseconds from now.
         *
         * @param delayMS the minimum delay time in milliseconds.
         * @return The next available schedule time in nanoseconds.
         */
        protected Long getSchedule(long delayMS) {
            Long schedule = Long.valueOf(System.nanoTime() + delayMS * 1000000);
            while (processes.get(schedule) != null) {
                schedule++;
            }
            return schedule;
        }

        @Override
        public void run() {
            if (LOGGER.isDebugEnabled()) {
                LOGGER.debug("ProcessTerminator is starting");
            }
            try {
                work(false);
            } catch (InterruptedException e) {
                Thread.interrupted();
                owner.clearWorker(this);
                LOGGER.debug("Shutting down ProcessTerminator");
                try {
                    work(true);
                } catch (InterruptedException e1) {
                    LOGGER.debug(
                            "ProcessTerminator interrupted while shutting down, terminating without terminating managed processes");
                } catch (Throwable e1) {
                    LOGGER.error(
                            "Unexpected error in ProcessTerminator while shutting down, terminating without terminating managed processes",
                            e.getClass().getSimpleName());
                }
            } catch (Throwable e) {
                owner.clearWorker(this);
                LOGGER.error("Unexpected error in ProcessTerminator, shutting down managed processes: {}",
                        e.getMessage());
                LOGGER.trace("", e);
                try {
                    work(true);
                } catch (InterruptedException e1) {
                    LOGGER.debug(
                            "ProcessTerminator interrupted while shutting down, terminating without terminating managed processes");
                } catch (Throwable e1) {
                    LOGGER.error(
                            "Unexpected error in ProcessTerminator while trying to shut down from a previous {}, "
                                    + "terminating without terminating managed processes",
                            e.getClass().getSimpleName());
                }
            }
            if (LOGGER.isDebugEnabled()) {
                LOGGER.debug("ProcessTerminator has stopped");
            }
        }

        /**
         * Performs the actual work in the {@link #run()} method.
         *
         * @param shutdown {@code true} if all managed {@link Process}es should
         *            be scheduled for immediate shutdown and return,
         *            {@code false} to keep running until interrupted.
         * @throws InterruptedException If interrupted during work.
         */
        protected void work(final boolean shutdown) throws InterruptedException {
            if (shutdown) {
                // Reschedule running processes for immediate shutdown
                ArrayList<ProcessInfo> reschedule = new ArrayList<>();
                for (Iterator<Entry<Long, ProcessInfo>> iterator = processes.entrySet().iterator(); iterator
                        .hasNext();) {
                    Entry<Long, ProcessInfo> entry = iterator.next();
                    if (entry.getValue().getState() == ProcessState.RUNNING) {
                        reschedule.add(entry.getValue());
                        iterator.remove();
                    }
                }
                int delay = 0;
                for (ProcessInfo processInfo : reschedule) {
                    if (processInfo.getTerminateTimeoutMS() > 500) {
                        processInfo.setTerminateTimeoutMS(500);
                    }
                    processes.put(getSchedule(delay++), processInfo);
                }
                if (LOGGER.isTraceEnabled() && !reschedule.isEmpty()) {
                    LOGGER.trace("ProcessTerminator rescheduled {} process{} for immediate shutdown",
                            reschedule.size(), reschedule.size() > 1 ? "es" : "");
                }
            }

            ProcessTicket currentTicket = null;
            while (true) {
                if (!shutdown) {
                    // Schedule incoming
                    do {
                        synchronized (owner.incoming) {
                            currentTicket = owner.incoming.poll();
                        }
                        if (currentTicket != null) {
                            if (currentTicket.getAction() == ProcessTicketAction.ADD) {
                                processes.put(getSchedule(currentTicket.getTimeoutMS()),
                                        new ProcessInfo(currentTicket.getProcess(), currentTicket.getName(),
                                                currentTicket.getTerminateTimeoutMS()));
                                if (LOGGER.isTraceEnabled()) {
                                    LOGGER.trace(
                                            "ProcessTerminator scheduled shutdown of process \"{}\" in {} milliseconds",
                                            currentTicket.getName(), currentTicket.getTimeoutMS());
                                }
                            } else if (currentTicket.getAction() == ProcessTicketAction.REMOVE) {
                                ProcessInfo remove = null;
                                for (Iterator<Entry<Long, ProcessInfo>> iterator = processes.entrySet()
                                        .iterator(); iterator.hasNext();) {
                                    Entry<Long, ProcessInfo> entry = iterator.next();
                                    if (entry.getValue().getProcess() == currentTicket.getProcess()) {
                                        remove = entry.getValue();
                                        iterator.remove();
                                    }
                                }
                                if (LOGGER.isDebugEnabled()) {
                                    if (remove == null) {
                                        LOGGER.debug("Couldn't find {} process to remove from process management",
                                                currentTicket.getName());
                                    } else if (LOGGER.isTraceEnabled()) {
                                        LOGGER.trace("ProcessTerminator unscheduled process \"{}\" ({})",
                                                remove.getName(), remove.getPID());
                                    }
                                }
                            } else if (currentTicket.getAction() == ProcessTicketAction.SHUTDOWN) {
                                ProcessInfo reschedule = null;
                                for (Iterator<Entry<Long, ProcessInfo>> iterator = processes.entrySet()
                                        .iterator(); iterator.hasNext();) {
                                    Entry<Long, ProcessInfo> entry = iterator.next();
                                    if (entry.getValue().getProcess() == currentTicket.getProcess()) {
                                        reschedule = entry.getValue();
                                        iterator.remove();
                                    }
                                }
                                if (reschedule != null) {
                                    if (currentTicket.getTerminateTimeoutMS() > 0) {
                                        reschedule.setTerminateTimeoutMS(
                                                Math.max(100, currentTicket.getTerminateTimeoutMS()));
                                    }
                                    processes.put(getSchedule(0), reschedule);
                                    if (LOGGER.isTraceEnabled()) {
                                        LOGGER.trace(
                                                "ProcessTerminator rescheduled process \"{}\" ({}) for immediate shutdown",
                                                reschedule.getName(), reschedule.getPID());
                                    }
                                } else if (LOGGER.isDebugEnabled()) {
                                    LOGGER.debug(
                                            "ProcessTerminator: No matching {} process found to reschedule for immediate shutdown");
                                }
                            } else {
                                throw new AssertionError("Unimplemented ProcessTicketAction");
                            }
                        }
                    } while (currentTicket != null);
                }

                //Process schedule
                while (!processes.isEmpty() && processes.firstKey().longValue() <= System.nanoTime()) {
                    ProcessInfo processInfo = processes.remove(processes.firstKey());
                    stopProcess(processInfo);
                }

                // Wait
                if (processes.isEmpty()) {
                    if (shutdown) {
                        break;
                    }
                    synchronized (owner.incoming) {
                        if (owner.incoming.isEmpty()) {
                            if (LOGGER.isTraceEnabled()) {
                                LOGGER.trace("ProcessTerminator is waiting for new tickets");
                            }
                            owner.incoming.wait();
                        }
                    }
                } else {
                    long waitTime = processes.firstKey().longValue() - System.nanoTime();
                    if (waitTime > 0) {
                        synchronized (owner.incoming) {
                            if (owner.incoming.isEmpty()) {
                                if (LOGGER.isTraceEnabled()) {
                                    if (waitTime < 1000000) {
                                        LOGGER.trace("ProcessTerminator is waiting {} nanoseconds", waitTime);
                                    } else {
                                        long waitTimeMS = waitTime / 1000000;
                                        LOGGER.trace("ProcessTerminator is waiting {} millisecond{}", waitTimeMS,
                                                waitTimeMS == 1 ? "" : "s");
                                    }
                                }
                                owner.incoming.wait(waitTime / 1000000, (int) (waitTime % 1000000));
                            }
                        }
                    }
                }
            }
        }
    }

    /**
     * This class represents a {@link Process} with corresponding orders to the
     * {@link ProcessTerminator}.
     *
     * @author Nadahar
     */
    @Immutable
    protected static class ProcessTicket {

        /** The ticket {@link Process} */
        protected final Process process;

        /** The ticket process name */
        protected final String processName;

        /** The ticket {@link ProcessTicketAction} */
        protected final ProcessTicketAction action;

        /** The timeout in milliseconds if applicable */
        protected final long timeoutMS;

        /** The termination timeout in milliseconds if applicable */
        protected final long terminateTimeoutMS;

        /**
         * Creates a new ticket using the specified parameters.
         *
         * @param process the ticket {@link Process}.
         * @param processName the ticket process name.
         * @param action the {@link ProcessTicketAction}.
         * @param timeoutMS the timeout in milliseconds if {@code action} is
         *            {@link ProcessTicketAction#ADD}.
         * @param terminateTimeoutMS the termination timeout in milliseconds if
         *            {@code action} is {@link ProcessTicketAction#ADD}.
         */
        public ProcessTicket(@Nonnull Process process, @Nonnull String processName,
                @Nonnull ProcessTicketAction action, long timeoutMS, long terminateTimeoutMS) {
            if (process == null) {
                throw new IllegalArgumentException("process cannot be null");
            }
            if (isBlank(processName)) {
                throw new IllegalArgumentException("processName cannot be blank");
            }
            if (action == null) {
                throw new IllegalArgumentException("action cannot be null");
            }
            this.process = process;
            this.processName = processName;
            this.action = action;
            this.timeoutMS = Math.max(0, timeoutMS);
            this.terminateTimeoutMS = Math.max(action == ProcessTicketAction.ADD ? 100 : 0, terminateTimeoutMS);
        }

        /**
         * @return The {@link Process}.
         */
        public Process getProcess() {
            return process;
        }

        /**
         * @return The {@link Process} name.
         */
        public String getName() {
            return processName;
        }

        /**
         * @return The {@link ProcessTicketAction}.
         */
        public ProcessTicketAction getAction() {
            return action;
        }

        /**
         * @return The process timeout in milliseconds.
         */
        public long getTimeoutMS() {
            return timeoutMS;
        }

        /**
         * @return The process terminate timeout in milliseconds.
         */
        public long getTerminateTimeoutMS() {
            return terminateTimeoutMS;
        }

        @Override
        public String toString() {
            return "ProcessTicket [Name=" + processName + ", Timeout=" + timeoutMS + " ms, Terminate Timeout="
                    + terminateTimeoutMS + " ms, Action=" + action + "]";
        }
    }

    /**
     * This {@code enum} represents an action/command in a {@link ProcessTicket}.
     *
     * @author Nadahar
     */
    protected static enum ProcessTicketAction {

        /** Add a process for management */
        ADD,

        /** Remove a process from management */
        REMOVE,

        /** Schedule a process for immediate shutdown */
        SHUTDOWN;
    }

    /**
     * This class represents a {@link Process} and its state information
     * relevant for {@link ProcessTerminator}.
     *
     * @author Nadahar
     */
    @NotThreadSafe
    protected static class ProcessInfo {

        /** The {@link Process} */
        protected final Process process;

        /** The process name used for logging and identification */
        protected final String processName;

        /** The process ID for the {@link Process} */
        protected final int pid;

        /** The termination timeout in milliseconds */
        protected long terminateTimeoutMS;

        /** The current {@link ProcessState} for the {@link Process} */
        protected ProcessState state = ProcessState.RUNNING;

        /**
         * Creates a new instance using the specified parameters.
         *
         * @param process the {@link Process}.
         * @param processName the process name used for logging and identification.
         * @param terminateTimeoutMS the termination timeout in milliseconds.
         */
        public ProcessInfo(@Nonnull Process process, @Nonnull String processName, long terminateTimeoutMS) {
            if (process == null) {
                throw new IllegalArgumentException("process cannot be null");
            }
            if (processName == null) {
                throw new IllegalArgumentException("processName cannot be null");
            }
            this.process = process;
            this.processName = processName;
            this.pid = getProcessId(process);
            if (this.pid == 0) {
                throw new IllegalStateException("Unable to retrieve process id");
            }
            this.terminateTimeoutMS = terminateTimeoutMS;
        }

        /**
         * @return The {@link Process}.
         */
        public Process getProcess() {
            return process;
        }

        /**
         * @return The {@link Process} name.
         */
        public String getName() {
            return processName;
        }

        /**
         * @return The process ID.
         */
        public int getPID() {
            return pid;
        }

        /**
         * @return The process terminate timeout in milliseconds.
         */
        public long getTerminateTimeoutMS() {
            return terminateTimeoutMS;
        }

        /**
         * Sets the terminate timeout value.
         *
         * @param terminateTimeoutMS the terminate timeout in milliseconds.
         */
        public void setTerminateTimeoutMS(long terminateTimeoutMS) {
            this.terminateTimeoutMS = terminateTimeoutMS;
        }

        /**
         * @return The current {@link ProcessState}.
         */
        public ProcessState getState() {
            return state;
        }

        /**
         * Sets the current {@link ProcessState}.
         *
         * @param state the {@link ProcessState} to set.
         */
        public void setState(ProcessState state) {
            this.state = state;
        }

        @Override
        public String toString() {
            return "ProcessInfo [Name=" + processName + ", PID=" + pid + ", State=" + state + "]";
        }
    }

    /**
     * This {@code enum} represents the states a {@link Process} can have in a
     * {@link ProcessTerminator} context.
     *
     * @author Nadahar
     */
    protected static enum ProcessState {

        /** Running; initial state */
        RUNNING,

        /** {@code WM_CLOSE} has been sent */
        WM_CLOSED,

        /** Ctrl + C has been sent */
        CTRL_C,

        /** TaskKill has been executed */
        TASKKILL,

        /** TerminateProcess has been called */
        TERMINATEPROCESS,

        /** {@link POSIXSignal#SIGTERM} has been sent */
        SIGTERM,

        /** {@link POSIXSignal#SIGALRM} has been sent */
        SIGALRM,

        /** {@link POSIXSignal#SIGKILL} has been sent */
        SIGKILL;
    }

    /**
     * This {@code enum} represents the different POSIX signals
     *
     * @author Nadahar
     */
    public static enum POSIXSignal {

        /**
         * 1: POSIX {@code SIGHUP} - Hangup detected on controlling terminal or
         * death of controlling process
         */
        SIGHUP(1),

        /** 2: POSIX {@code SIGINT} - Interrupt from keyboard */
        SIGINT(2),

        /** 3: POSIX {@code SIGQUIT} - Quit from keyboard */
        SIGQUIT(3),

        /** 4: POSIX {@code SIGILL} - Illegal Instruction */
        SIGILL(4),

        /** 6: POSIX {@code SIGABRT} - Abort signal from abort() */
        SIGABRT(6),

        /** 8: POSIX {@code SIGFPE} - Floating-point exception */
        SIGFPE(8),

        /** 9: POSIX {@code SIGKILL} - Kill signal */
        SIGKILL(9),

        /** 11: POSIX {@code SIGSEGV} - Invalid memory reference */
        SIGSEGV(11),

        /**
         * 13: POSIX {@code SIGPIPE} - Broken pipe: write to pipe with no
         * readers
         */
        SIGPIPE(13),

        /** 14: POSIX {@code SIGALRM} - Timer signal from alarm() */
        SIGALRM(14),

        /** 15: POSIX {@code SIGTERM} - Termination signal */
        SIGTERM(15);

        private final int value;

        private POSIXSignal(int value) {
            this.value = value;
        }

        /**
         * @return The integer value.
         */
        public int getValue() {
            return value;
        }

        @Override
        public String toString() {
            return name() + " (" + value + ")";
        }
    }
}