/ src / library / CrawlDaemon.php
<?php
/**
 * SeekQuarry/Yioop --
 * Open Source Pure PHP Search Engine, Crawler, and Indexer
 *
 * Copyright (C) 2009 - 2026  Chris Pollett chris@pollett.org
 *
 * LICENSE:
 *
 * 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 <https://www.gnu.org/licenses/>.
 *
 * END LICENSE
 *
 * @author Chris Pollett chris@pollett.org
 * @license https://www.gnu.org/licenses/ GPL3
 * @link https://www.seekquarry.com/
 * @copyright 2009 - 2026
 * @filesource
 */
namespace seekquarry\yioop\library;

use seekquarry\atto as A;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library\CrawlConstants;

/**
 * Load the crawlLog function
 */
require_once C\BASE_DIR."/library/Utility.php";
/**
 * Used to run scripts as a daemon on *nix systems
 *
 * @author Chris Pollett
 */
class CrawlDaemon implements CrawlConstants
{
    /**
     * Name prefix to be used on files associated with this daemon
     * (such as lock like and messages)
     * @var string
     * @static
     */
    public static $name;
    /**
     * Subname of the name prefix used on files associated with this daemon
     * For example, the name might be fetcher, the subname might 2 to indicate
     * which fetcher daemon instance.
     *
     * @var string
     * @static
     */
    public static $subname;
    /**
     * Used by processHandler to decide whether run as daemon or not
     * @var string
     * @static
     */
    public static $mode;
    /**
     * Absolute path to a file whose presence signals every
     * CrawlDaemon-managed process to stop at its next checkpoint
     */
    const GLOBAL_STOP_FILE_NAME = C\DATA_DIR . "/GlobalStop.txt";
    /**
     * Names of daemons whose current work can be cut off at any
     * point without leaving anything in a bad state, so a stop may
     * interrupt them in the middle of a job rather than wait for the
     * job to finish. MediaUpdater's jobs are like this: an
     * interrupted mail batch is re-gathered and an interrupted
     * transcription is re-run on the next pass. Daemons that write
     * long-lived index data are deliberately left out, because
     * cutting them off mid-write could damage that data; they stop
     * at their own checkpoints instead.
     * @var array
     */
    const PROMPT_STOP_DAEMONS = ["MediaUpdater"];
    /**
     * Callback function used to update the timestamp in this processes
     * lock. If lock_file does not exist or more than PROCESS_TIMEOUT
     * time has elapsed since the last processHandler call it stops the process
     *
     * @param bool $continue if true only stop if lock file not present,
     *   ignore PROCESS_TIMEOUT time being exceeded.
     * @return bool always true (the function only returns on the
     *   keep-running paths; the stop paths exit the process before
     *   returning)
     */
    public static function processHandler($continue = false)
    {
        static $time = 0;
        static $start_time = 0;
        $lock_file = CrawlDaemon::getLockFileName(self::$name, self::$subname);
        if (empty(self::$name) && empty(self::$subname)) {
            crawlLog("!!Process handler is being call without naming info ".
                " letting process live but should check!!", null, true);
            return true;
        }
        if (!C\PROFILE) {
            /* The instance has not been configured yet (no Profile.php),
               so the work directory does not exist and the timing
               constants this method relies on, such as PROCESS_TIMEOUT,
               have not been defined. A daemon in this state is a fresh
               install waiting to serve the page that creates the work
               directory, so keep it alive and skip the timeout policing
               until configuration has happened. */
            return true;
        }
        $now = time();
        if (!empty(self::$name)) {
            /* record how this daemon was launched so an own-web-
               server restart can tell a foreground (terminal) run
               apart from one started in the background through
               Manage Machines. Written next to the lock so it
               tracks the live process. */
            $origin_file = self::getOriginFileName(self::$name,
                self::$subname);
            $origin = (self::$mode == 'daemon') ? 'daemon' :
                'terminal';
            file_put_contents($origin_file, $origin);
        }
        if (self::$mode != 'daemon' && !empty(self::$name)) {
            self::writeLockFile($lock_file, $now);
            return true;
        }
        if ($time == 0 ) {
            $time = $now;
        }
        if ($start_time == 0) {
            $start_time = $now;
        }
        $global_stop_file = self::GLOBAL_STOP_FILE_NAME;
        if (file_exists($global_stop_file)) {
            $stop_time = intval(file_get_contents($global_stop_file));
            $name_string = CrawlDaemon::getNameString(self::$name,
                self::$subname);
            if ($stop_time > $start_time) {
                if (file_exists($lock_file)) {
                    unlink($lock_file);
                }
                crawlLog("Received Stop All Message", null, true);
                crawlLog("Stopping $name_string ...", null, true);
                exit();
            } else {
                unlink($global_stop_file);
            }
        }
        $lock_exist = file_exists($lock_file);
        if (!$lock_exist || ($now - $time) > C\PROCESS_TIMEOUT) {
            $name_string = CrawlDaemon::getNameString(self::$name,
                self::$subname);
            if (($now - $time) > C\PROCESS_TIMEOUT) {
                crawlLog($name_string . ": ".($now - $time) .
                    " seconds has elapsed since processHandler last called.",
                    null, true);
                crawlLog("Timeout exceeded...", null, true);
            }
            if (!$lock_exist || !$continue) {
                crawlLog("Stopping $name_string ...", null, true);
                exit();
            }
        }
        $time = $now;
        self::writeLockFile($lock_file, $now);
        return true;
    }
    /**
     * Used to send a message the given daemon or run the program in the
     * foreground.
     *
     * @param array $init_argv an array of command line arguments. The argument
     *     start will check if the process control functions exists if these
     *     do they will fork and detach a child process to act as a daemon.
     *     a lock file will be created to prevent additional daemons from
     *     running. If the message is stop then a message file is written to
     *     tell the daemon to stop. If the argument is terminal then the
     *     program won't be run as a daemon.
     * @param string $name the prefix to use for lock and message files
     * @param int $exit_type whether this function should exit > 0 or return (1)
     *     by default a lock file is only written if exit (this allows
     *     both queue server processes (Indexer and Scheduler) to use the
     *     same lock file. If exit is >=3 or <= -3 then doesn't check lock
     *     to see if already running before starting
     * @param string $use_message echo'd if incorrect parameters sent
     */
    public static function init($init_argv, $name, $exit_type = 1,
        $use_message = "")
    {
        $use_message = ($use_message) ? $use_message :
            "$name needs to be run with a command-line argument.\n" .
            "For example,\n" .
            "php $name.php start //starts the $name as a daemon\n" .
            "php $name.php stop //stops the $name daemon\n" .
            "php $name.php terminal //runs $name within the current\n" .
            "\tprocess, not as a daemon, output going to the terminal\n" .
            "Additional arguments are described in Yioop documentation.\n";
        self::$name = $name;
        if (isset($init_argv[2]) && $init_argv[2] != "none") {
            self::$subname = $init_argv[2];
        } else {
            self::$subname = "";
        }
        //don't let our script be run from apache
        if (isset($_SERVER['DOCUMENT_ROOT']) &&
            strlen($_SERVER['DOCUMENT_ROOT']) > 0) {
            echo "BAD REQUEST";
            exit();
        }
        if (!isset($init_argv[1])) {
            echo $use_message;
            exit();
        }
        $messages_file = self::getMessageFileName(self::$name, self::$subname);
        switch ($init_argv[1]) {
            case "child":
                self::$mode = 'daemon';
                $info = [];
                $info[self::STATUS] = self::WAITING_START_MESSAGE_STATE;
                if ($name != 'index') {
                    file_put_contents($messages_file, serialize($info));
                    chmod($messages_file, 0777);
                }
                $_SERVER["LOG_TO_FILES"] = true;
                    // if false log messages are sent to the console
                break;
            case "debug":
                $num_args = count($init_argv);
                if ($num_args <= 3) {
                    echo "Too few args. Might need to specify channel.\n";
                } else if ($num_args > 3) {
                    $last_arg = $init_argv[$num_args - 1];
                    echo $messages_file;
                    $info = [];
                    $info[self::DEBUG] = $last_arg;
                    file_put_contents($messages_file, serialize($info));
                    chmod($messages_file, 0777);
                }
                exit();
            case "start":
                $options = "";
                $quote = (strstr(PHP_OS, "WIN")) ? '' : '"';
                for ($i = 3; $i < count($init_argv); $i++) {
                    $options .= $quote . $init_argv[$i] . $quote . " ";
                }
                $options = trim($options);
                $subname = (!isset($init_argv[2]) || $init_argv[2] == 'none') ?
                    'none' : self::$subname;
                $name_prefix = (isset($init_argv[3])) ? $init_argv[3] :
                    self::$subname;
                $name_string = CrawlDaemon::getNameString($name, $name_prefix);
                self::daemonLog("Starting $name_string...", $exit_type);
                self::daemonLog("options: $name, $subname, $options",
                    $exit_type);
                CrawlDaemon::start($name, $subname, $options, $exit_type);
                break;
            case "stop":
                CrawlDaemon::stop($name, self::$subname);
                break;
            case "terminal":
                self::$mode = 'terminal';
                $info = [];
                $info[self::STATUS] = self::WAITING_START_MESSAGE_STATE;
                if ($name != 'index') {
                    file_put_contents($messages_file, serialize($info));
                    chmod($messages_file, 0777);
                }
                $_SERVER["LOG_TO_FILES"] = false;
                break;
            default:
                echo $use_message;
                exit();
        }
    }
    /**
     * Used to print a log message in a way helpful to aid debugging
     * CrawlDaemon tasks where crawlLog() might not yet be set up
     * Sends the message to standard out if crawlLog not set up; otherwise,
     * sends to crawlLog()
     *
     * @param string $msg string to log to either standard out or
     *  to Yioop's crawlLog
     * @param int $exit_type the exit_type used by init() and start()
     *  values of absolute value >2 are only used if crawlLog has
     *  already been set up
     */
    public static function daemonLog($msg, $exit_type)
    {
        if (in_array($exit_type, [-2, -1, 0, 1, 2])) {
            echo "$msg\n";
        } else {
            crawlLog($msg);
        }
    }
    /**
     * Used to start a daemon running in the background
     *
     * @param string $name the main name of this daemon such as queue_server
     *     or fetcher.
     * @param string $subname the instance name if it is possible for more
     *     than one copy of the daemon to be running at the same time
     * @param string $options a string of additional command line options
     * @param int $exit whether this function should exit > 0 or return (1)
     *     by default a lock file is only written if exit (this allows
     *     both queue server processes (Indexer and Scheduler) to use the
     *     same lock file. If exit is >=3 or <= -3 then doesn't check lock
     *     to see if already running before starting
     */
    public static function start($name, $subname = "", $options = "", $exit = 1)
    {
        if (empty($name)) {
            echo "Must provide a non-empty daemon name";
            exit();
        }
        $tmp_subname = ($subname == 'none') ? '' : $subname;
        $lock_file = CrawlDaemon::getLockFileName($name, $tmp_subname);
        $alt_subname =  ($subname == "0") ? "" : ($subname == "" ?
            "0" : "-1");
        $alt_lock_file = $lock_file;
        if ($alt_subname != "-1") {
            /* this is designed to handle a case where run as terminal
               CTRL-C exit so still have a lock file, then start in background
               a queue server
             */
            $alt_lock_file = CrawlDaemon::getLockFileName($name, $alt_subname);
        }
        if ((file_exists($lock_file) || file_exists($alt_lock_file))
            && ($exit < 3 && $exit > -3)) {
            $present_lock = (file_exists($lock_file)) ?
                $lock_file : $alt_lock_file;
            if (self::lockHeldByLiveProcess($present_lock)) {
                echo "$name appears to be already running...\n";
                echo "Try stopping it first (php process_name stop), ".
                    "then running start.\n";
                exit();
            }
            if (file_exists($present_lock)) {
                unlink($present_lock);
            }
        }
        if (preg_match('/(%s|\/|\\\)/', $name)) {
            echo "Illegal input";
            exit();
        }
        if (strstr(PHP_OS, "WIN")) {
            if ($name == 'index') {
                $parent_dir = str_replace("/", "\\", C\PARENT_DIR);
                $script = "$parent_dir\\index.php";
            } else {
                $base_dir = str_replace("/", "\\", C\BASE_DIR);
                $script = $base_dir . "\\executables\\$name.php";
            }
            $total_options = "child $subname $options";
        } else {
            if ($name == 'index') {
                $script = "'" . C\PARENT_DIR . "/index.php'";
            } else {
                $script = "'" . C\BASE_DIR . "/executables/$name.php'";
            }
            $total_options = "child \"$subname\" $options";
        }
        self::execScriptInOwnProcess($script, $total_options);
        if ($exit != 0) {
            self::writeLockFile($lock_file, time());
        }
        if ($exit > 0) {
            if (function_exists("seekquarry\\atto\\webExit") &&
                $name != 'index') {
                \seekquarry\atto\webExit();
            } else {
                exit();
            }
        }
    }
    /**
     * Launches a command as its own independent background process.
     *
     * Where the system provides the process-control functions, this
     * forks the current process and has the fork become the command.
     * Forking lets the new process drop the file descriptors it
     * inherited from this one - in particular a running web server's
     * listening sockets - before it starts, so the launched program
     * does not keep those ports open. Holding an inherited listening
     * socket is what previously kept a port in use after the server
     * tried to restart, so the replacement could not rebind it.
     * Without the process-control functions (for example on Windows)
     * it falls back to a plain background shell launch, which starts
     * the program but cannot drop the inherited sockets.
     *
     * SECURITY WARNING: Do not use with computable arguments!!
     *
     * @param string $cmd the command to execute
     * @param string $output where to redirect the command's output
     */
    public static function execInOwnProcess($cmd, $output = '/dev/null')
    {
        $is_windows = (strstr(PHP_OS, "WIN") !== false);
        if (!$is_windows && self::canForkLaunch()) {
            self::forkLaunch($cmd, $output);
            return;
        }
        $job = self::composeBackgroundJob($cmd, $output, $is_windows);
        pclose(popen($job, "r"));
    }
    /**
     * Reports whether the process-control functions needed to launch
     * a command by forking are all present. When any is missing the
     * caller launches through a background shell command instead.
     *
     * @return bool true when forking to launch is possible
     */
    public static function canForkLaunch()
    {
        return function_exists("pcntl_fork")
            && function_exists("pcntl_exec")
            && function_exists("posix_setsid");
    }
    /**
     * Launches $cmd as an independent background process by forking.
     *
     * The launch uses two forks. The first lets this process return
     * to its own work immediately while the launched program runs on
     * its own. The fork then starts a new session and forks a second
     * time; the middle process ends so the final one is handed to the
     * system's init process and can never linger as an unreaped,
     * defunct entry. That final process closes the listening sockets
     * it inherited from this one, then replaces itself with the
     * requested command run through a shell that reads nothing from
     * standard input and sends the command's output to the requested
     * file. The helper processes created along the way are ended
     * without running PHP's normal shutdown, because they share this
     * process's open connections (a database handle, for example) and
     * a normal shutdown would close those shared connections and break
     * them for this process, which keeps running. If the first fork
     * cannot be made it falls back to a plain background shell launch.
     *
     * @param string $cmd the command to run
     * @param string $output file path the command's output goes to
     */
    public static function forkLaunch($cmd, $output)
    {
        $launched = pcntl_fork();
        if ($launched < 0) {
            $job = self::composeBackgroundJob($cmd, $output, false);
            pclose(popen($job, "r"));
            return;
        }
        if ($launched > 0) {
            pcntl_waitpid($launched, $status);
            return;
        }
        posix_setsid();
        $final = pcntl_fork();
        if ($final !== 0) {
            self::endForkedHelper();
        }
        self::closeInheritedListeningSockets();
        pcntl_exec("/bin/sh", ["-c",
            "exec " . $cmd . " </dev/null > " . $output . " 2>&1"]);
        self::endForkedHelper();
    }
    /**
     * Ends a short-lived helper process made by forking, at once and
     * without running PHP's normal shutdown. The process that made it
     * shares its open connections, since a fork copies them; running
     * the usual shutdown here would close those shared connections - a
     * database handle most of all - and break them for the process
     * that is still using them. An immediate kill runs no cleanup, so
     * the shared connections are left untouched. Where the kill
     * functions are absent a plain exit is the fallback.
     */
    public static function endForkedHelper()
    {
        if (function_exists("posix_kill")
            && function_exists("posix_getpid")) {
            posix_kill(posix_getpid(), SIGKILL);
        }
        exit(0);
    }
    /**
     * Closes the listening sockets this process inherited so a process
     * about to become a separate program does not keep a web server's
     * listening sockets open - holding one keeps its port in use and
     * blocks the server from rebinding it on a restart. Only listening
     * sockets are closed: a socket with no connected peer. Connected
     * sockets (a request in progress, a database link) are left alone,
     * because closing an inherited connection here can send a shutdown
     * notice on the shared link and break it for the launching
     * process. Each close acts only on this process's own copy of the
     * descriptor, so the launching process keeps its sockets.
     */
    public static function closeInheritedListeningSockets()
    {
        foreach (get_resources("stream") as $resource) {
            if (!is_resource($resource)) {
                continue;
            }
            $details = stream_get_meta_data($resource);
            $kind = isset($details["stream_type"]) ?
                $details["stream_type"] : "";
            if (strpos($kind, "socket") === false) {
                continue;
            }
            $peer = @stream_socket_get_name($resource, true);
            if ($peer === false || $peer === "") {
                @fclose($resource);
            }
        }
    }
    /**
     * Builds the shell command that launches $cmd in the background
     * with its input taken from nowhere and its output sent to
     * $output, so the launched program keeps running on its own after
     * the program that started it has gone. On Windows the built-in
     * "start" launcher is used; everywhere else the program is simply
     * put in the background with a trailing ampersand.
     *
     * An earlier version wrapped the command in a shell loop that
     * closed every inherited file descriptor above standard error
     * before starting the program, so that a restarted web server
     * would not hold a copy of the old listening socket and could
     * rebind its port. That blanket close was a mistake: it also
     * closed the descriptors the launching shell itself relies on, so
     * on some systems (notably the dash shell used as /bin/sh on many
     * Linux installs) the shell died part way through the loop and
     * never reached the step that starts the program, so the daemon
     * silently failed to start. The descriptor cleanup has been
     * removed; keeping a restarted server from inheriting the old
     * socket belongs with the socket itself, not in this launch line.
     *
     * @param string $cmd program and arguments to run
     * @param string $output file path the program's output goes to
     * @param bool $is_windows whether the host is Windows, which
     *      selects the launch syntax; passed in rather than detected
     *      so the construction can be checked on any host
     * @return string a command line suitable for popen
     */
    public static function composeBackgroundJob($cmd, $output,
        $is_windows)
    {
        if ($is_windows) {
            return "start /B $cmd ";
        }
        return "$cmd < /dev/null > $output &";
    }
    /**
     * Used to execute a php script in its own process
     * SECURITY WARNING: Do not use with a computable arguments!!
     *
     * @param string $script_name name of a PHP script
     * @param string $options command line options to send script
     * @param string $output where to redirect output to
     */
    public static function execScriptInOwnProcess($script_name, $options = "",
        $output = '/dev/null')
    {
        $php = "php";
        if (C\nsdefined("PHP_PATH") ) {
            $php = C\PHP_PATH . "/" . $php;
            if (strstr(PHP_OS, "WIN")) {
                $php = str_replace("/", "\\", $php);
            }
        }
        /* make sure hhvm has write access to the folder
           of the owner of the webserver process so it can write
           a .hhvm.hhbc file
         */
        if (function_exists("posix_getpwuid")) {
            $process_user_info = posix_getpwuid(posix_getuid());
            $process_home = $process_user_info['dir'];
            if (C\nsdefined("FORCE_HHVM") || (
                stristr(phpversion(), "hhvm") !== false &&
                posix_access($process_home, POSIX_W_OK))) {
                $php = 'hhvm -f ';
                if (C\nsdefined("HHVM_PATH") ) {
                    $php = C\HHVM_PATH . "/" . $php;
                }
            }
        }
        self::execInOwnProcess("$php $script_name $options", $output);
    }
    /**
     * Reports whether a stop of the named daemon may interrupt it in
     * the middle of a job by signalling it, rather than only removing
     * its lock and letting it stop at its next checkpoint. True only
     * for daemons listed as safe to cut off (PROMPT_STOP_DAEMONS) and
     * only where the system provides the signal-sending function, so a
     * caller can fall back to the lock-only stop where it cannot
     * signal.
     *
     * @param string $name daemon name such as MediaUpdater
     * @return bool true when the daemon may be signalled to stop now
     */
    public static function mayInterruptOnStop($name)
    {
        return in_array($name, self::PROMPT_STOP_DAEMONS, true)
            && function_exists("posix_kill");
    }
    /**
     * Sends a stop signal to the named daemon when it is one that may
     * be interrupted mid-job and its lock records a live process id,
     * so it ends promptly rather than after finishing the current
     * job. Does nothing for daemons that stop only at their own
     * checkpoints, when no process id was recorded, or when the
     * process is already gone. The lock file itself is removed by the
     * caller.
     *
     * @param string $lock_file path of the daemon's lock file
     * @param string $name daemon name such as MediaUpdater
     */
    protected static function signalDaemonStop($lock_file, $name)
    {
        if (!self::mayInterruptOnStop($name)) {
            return;
        }
        $process_id = self::lockProcessId($lock_file);
        if ($process_id > 0 && self::isProcessAlive($process_id)) {
            /* 15 is SIGTERM, matching the drain escalation */
            posix_kill($process_id, 15);
        }
    }
    /**
     * Used to stop a daemon that is running in the background
     *
     * @param string $name the main name of this daemon such as QueueServer
     *     or Fetcher.
     * @param string $subname the instance name if it is possible for more
     *     than one copy of the daemon to be running at the same time
     * @param bool $exit whether this method should just return (false) or
     *      call exit() (true)
     */
    public static function stop($name, $subname = "", $exit = true)
    {
        $name_string = CrawlDaemon::getNameString($name, $subname);
        $lock_file = CrawlDaemon::getLockFileName($name, $subname);
        $not_web_setting = (php_sapi_name() == 'cli' &&
            !defined("seekquarry\\yioop\\configs\\IS_OWN_WEB_SERVER"));
        foreach ([$subname, ($subname == "") ? "0" : $subname] as
            $origin_subname) {
            $origin_file = CrawlDaemon::getOriginFileName($name,
                $origin_subname);
            if (file_exists($origin_file)) {
                unlink($origin_file);
            }
        }
        if (file_exists($lock_file)) {
            self::signalDaemonStop($lock_file, $name);
            unlink($lock_file);
            if ($not_web_setting) {
                crawlLog("Sending stop signal to $name_string...");
            }
        } else if (empty($subname)) {
            $lock_file = CrawlDaemon::getLockFileName($name, "0");
            if (file_exists($lock_file)) {
                self::signalDaemonStop($lock_file, $name);
                unlink($lock_file);
                if ($not_web_setting) {
                    crawlLog("Sending stop signal to $name_string...");
                }
            }
        } else if ($not_web_setting) {
            crawlLog("$name_string does not appear to running...");
        }
        if ($exit) {
            if ($name == 'index') {
                $global_stop_file = self::GLOBAL_STOP_FILE_NAME;
                file_put_contents($global_stop_file, time());
                chmod($global_stop_file, 0777);
                exit();
            }
            if (function_exists("seekquarry\\atto\\webExit")) {
                \seekquarry\atto\webExit();
            } else {
                exit();
            }
        }
    }
    /**
     * Used to return the string name of the messages file used to pass
     * messages to a daemon running in the background
     *
     * @param string $name the main name of this daemon such as queue_server
     *     or fetcher.
     * @param string $subname the instance name if it is possible for more
     *     than one copy of the daemon to be running at the same time
     *
     * @return string the name of the message file for the daemon with
     *     the given name and subname
     */
    public static function getMessageFileName($name, $subname = "")
    {
        return C\SCHEDULES_DIR . "/" . self::getNameString($name, $subname)
            . "Messages.txt";
    }
    /**
     * Used to return the string name of the lock file used to pass
     * by a daemon
     *
     * @param string $name the main name of this daemon such as queue_server
     *     or fetcher.
     * @param string $subname the instance name if it is possible for more
     *     than one copy of the daemon to be running at the same time
     *
     * @return string the name of the lock file for the daemon with
     *     the given name and subname
     */
    public static function getLockFileName($name, $subname = "")
    {
        return C\SCHEDULES_DIR . "/" . self::getNameString($name, $subname)
            . "Lock.txt";
    }
    /**
     * Writes a daemon lock file holding the current time and, when
     * the running process id is known, that id after a space. Existing
     * readers that intval() the file still get the timestamp because
     * intval stops at the space, so the recorded process id is purely
     * additional information used by isProcessAlive for stale-lock
     * detection.
     *
     * @param string $lock_file absolute path of the lock file to write
     * @param int $now timestamp to record as the lock heartbeat
     */
    public static function writeLockFile($lock_file, $now)
    {
        $process_id = function_exists("getmypid") ? getmypid() : false;
        $contents = ($process_id === false) ? (string)$now :
            $now . " " . $process_id;
        file_put_contents($lock_file, $contents);
    }
    /**
     * Reads the process id recorded in a daemon lock file, if any.
     * The lock file holds "timestamp" or "timestamp processid"; this
     * returns the second field as an int, or 0 when none is present
     * (for example a lock written by an older release or by the
     * parent before the child recorded its own id).
     *
     * @param string $lock_file absolute path of the lock file to read
     * @return int the recorded process id, or 0 when none is recorded
     */
    public static function lockProcessId($lock_file)
    {
        if (!file_exists($lock_file)) {
            return 0;
        }
        $parts = explode(" ", trim((string)file_get_contents($lock_file)));
        if (count($parts) < 2) {
            return 0;
        }
        return intval($parts[1]);
    }
    /**
     * Reports whether a daemon lock file is held by a process that is
     * actually still running. When the lock records a process id (every
     * lock written by current code does), that id settles it directly,
     * so a server that crashed or was Ctrl-C'd is recognized as gone at
     * once rather than after the timestamp-freshness window. Only a lock
     * with no recorded id (an older release, or a system without the
     * POSIX functions) falls back to treating a timestamp written
     * within the short STALE_LOCK_TIMEOUT window as still-running.
     *
     * @param string $lock_file absolute path of the lock file to test
     * @return bool true if a live process holds the lock, false when the
     *      lock is absent or left behind by a process that has ended
     */
    public static function lockHeldByLiveProcess($lock_file)
    {
        if (!file_exists($lock_file)) {
            return false;
        }
        $process_id = self::lockProcessId($lock_file);
        if ($process_id > 0) {
            return self::isProcessAlive($process_id);
        }
        $time = intval(file_get_contents($lock_file));
        return (time() - $time) < C\STALE_LOCK_TIMEOUT;
    }
    /**
     * Reports whether a process id refers to a process that is
     * functions are available (the preferred, dependency-light check).
     * When POSIX is not available it returns true so behavior falls
     * back to the timestamp-freshness heuristic rather than wrongly
     * declaring a live daemon dead.
     *
     * @param int $process_id the process id to test
     * @return bool true if the process is running or cannot be
     *      determined, false only when known not to be running
     */
    public static function isProcessAlive($process_id)
    {
        if ($process_id <= 0) {
            return true;
        }
        if (!function_exists("posix_kill")) {
            return true;
        }
        if (posix_kill($process_id, 0)) {
            return true;
        }
        /* A false result can mean the process is gone, or that it is
           owned by another user (for example a server started with
           sudo, checked by a non-root command). Being told we are not
           allowed to signal it proves it exists. The POSIX error for
           that is EPERM, whose number is 1; only the no-such-process
           error (ESRCH) means it is actually dead. */
        $permission_denied = 1;
        if (function_exists("posix_get_last_error") &&
            posix_get_last_error() === $permission_denied) {
            return true;
        }
        return false;
    }
    /**
     * Waits for a daemon that has just been sent a stop signal to
     * actually exit, reporting progress through $notify so a caller
     * can surface a "Waiting on ..." message. Polls once a second up
     * to OWN_SERVER_DAEMON_DRAIN_TIMEOUT seconds. If the daemon is
     * still alive at the timeout and POSIX is available a SIGTERM is
     * sent and the lock removed; if POSIX is not available $notify is
     * asked to relay how the operator can end the process by hand.
     * When $process_id is not known (an older lock without a recorded
     * id) liveness cannot be confirmed, so the wait relies on the
     * lock file disappearing instead.
     *
     * @param string $name daemon name such as MediaUpdater
     * @param int $process_id id recorded in the lock before stopping,
     *      or 0 when none was recorded
     * @param callable $notify called with a status string while
     *      waiting and on escalation; may be null for no output
     * @return bool true if the daemon exited (on its own or via
     *      SIGTERM), false if it could not be confirmed stopped
     */
    public static function drainDaemon($name, $process_id,
        $notify = null)
    {
        $lock_file = self::getLockFileName($name, "");
        $waited = 0;
        while ($waited < C\OWN_SERVER_DAEMON_DRAIN_TIMEOUT) {
            clearstatcache(true, $lock_file);
            $lock_gone = !file_exists($lock_file);
            $process_done = ($process_id > 0) ?
                !self::isProcessAlive($process_id) : $lock_gone;
            if ($lock_gone && $process_done) {
                return true;
            }
            if ($notify !== null) {
                $notify("Waiting on " . $name . " to stop...");
            }
            sleep(1);
            $waited++;
        }
        if ($process_id > 0 && function_exists("posix_kill") &&
            self::isProcessAlive($process_id)) {
            if ($notify !== null) {
                $notify($name . " did not stop in time; sending " .
                    "SIGTERM and clearing its lock.");
            }
            posix_kill($process_id, 15);
            clearstatcache(true, $lock_file);
            if (file_exists($lock_file)) {
                unlink($lock_file);
            }
            return true;
        }
        clearstatcache(true, $lock_file);
        if (!file_exists($lock_file)) {
            return true;
        }
        if ($notify !== null) {
            $notify($name . " is still running and could not be " .
                "stopped automatically (POSIX is not available). " .
                "End it by hand, for example: php " . $name .
                ".php stop");
        }
        return false;
    }
    /**
     * Performs one non-blocking check toward draining a stopped
     * daemon, for callers that run inside an event loop and must not
     * block. Unlike drainDaemon this never sleeps; it inspects the
     * lock and process once and returns where the drain stands. The
     * caller is responsible for calling again on a later tick while
     * the return value is "waiting", and supplies how many seconds
     * have elapsed so a SIGTERM can be sent once the drain timeout is
     * reached.
     *
     * @param string $name daemon name such as MediaUpdater
     * @param int $process_id id recorded before stopping, or 0 when
     *      none was recorded
     * @param int $waited seconds elapsed since the drain began
     * @return string "stopped" when the daemon has exited (on its own
     *      or via a just-sent SIGTERM), "stuck" when the timeout
     *      passed and POSIX is not available to force it, or "waiting"
     *      when more time should be given
     */
    public static function drainDaemonStep($name, $process_id, $waited)
    {
        $lock_file = self::getLockFileName($name, "");
        clearstatcache(true, $lock_file);
        $lock_gone = !file_exists($lock_file);
        $process_done = ($process_id > 0) ?
            !self::isProcessAlive($process_id) : $lock_gone;
        if ($lock_gone && $process_done) {
            return "stopped";
        }
        if ($waited < C\OWN_SERVER_DAEMON_DRAIN_TIMEOUT) {
            return "waiting";
        }
        if ($process_id > 0 && function_exists("posix_kill") &&
            self::isProcessAlive($process_id)) {
            posix_kill($process_id, 15);
            clearstatcache(true, $lock_file);
            if (file_exists($lock_file)) {
                unlink($lock_file);
            }
            return "stopped";
        }
        clearstatcache(true, $lock_file);
        if (!file_exists($lock_file)) {
            return "stopped";
        }
        return "stuck";
    }
    /**
     * Absolute path of the file recording the current phase of an
     * own-web-server restart (for example "stopping", "waiting" on a
     * named daemon, or "relaunching"). The Restart Server page polls a
     * status route that reads this so the browser can show progress
     * across the moment the server itself restarts. The file is
     * removed when the restart finishes.
     *
     * @return string absolute path of the restart status file
     */
    public static function restartStatusFileName()
    {
        return C\DATA_DIR . "/RestartStatus.txt";
    }
    /**
     * Stops the background daemons (Media Updater, Mail Server) that
     * this own-web-server started, and records which ones to bring back
     * so the replacement process can relaunch them after it restarts.
     * Daemons a person started in the foreground with their own
     * terminal are left running and not recorded. Shared by the admin
     * Restart Server action and a command-line restart so both take the
     * daemons down the same way, leaving one place to fix restart
     * behavior.
     *
     * @return void
     */
    public static function saveDaemonStateForRestart()
    {
        if (!defined("seekquarry\\yioop\\configs\\IS_OWN_WEB_SERVER")) {
            return;
        }
        $statuses = self::statuses();
        $to_relaunch = [];
        foreach (["MediaUpdater", "MailServer"] as $daemon) {
            if (empty($statuses[$daemon])) {
                continue;
            }
            if (self::getOrigin($daemon) !== "daemon") {
                /* a foreground (terminal) run, or origin unknown;
                   leave it running and do not relaunch it */
                continue;
            }
            $lock_file = self::getLockFileName($daemon, "");
            $process_id = self::lockProcessId($lock_file);
            $to_relaunch[] = $daemon . " " . $process_id;
            self::stop($daemon, "", false);
        }
        $marker = C\DATA_DIR . "/RestartDaemons.txt";
        if (empty($to_relaunch)) {
            if (file_exists($marker)) {
                unlink($marker);
            }
            self::setRestartStatus("");
            return;
        }
        self::setRestartStatus("Stopping services...");
        file_put_contents($marker, implode("\n", $to_relaunch));
    }
    /**
     * Records the current restart phase so the Restart Server page can
     * report it. Passing the empty string clears the phase, which the
     * status route reports as "done".
     *
     * @param string $phase short text describing the current phase
     */
    public static function setRestartStatus($phase)
    {
        $status_file = self::restartStatusFileName();
        if ($phase === "") {
            clearstatcache(true, $status_file);
            if (file_exists($status_file)) {
                unlink($status_file);
            }
            return;
        }
        file_put_contents($status_file, $phase);
    }
    /**
     * Reads the current restart phase, or the empty string when no
     * restart is in progress.
     *
     * @return string the recorded phase, or "" when none is set
     */
    public static function getRestartStatus()
    {
        $status_file = self::restartStatusFileName();
        clearstatcache(true, $status_file);
        if (!file_exists($status_file)) {
            return "";
        }
        return trim((string)file_get_contents($status_file));
    }
    /**
     * Returns the path of the file recording how a daemon was
     * launched ("terminal" when run in the foreground with
     * `php Name.php terminal`, or "daemon" when started in the
     * background through CrawlDaemon::start, e.g. from Manage
     * Machines). The own-web-server restart reads this to decide
     * which daemons to leave running and which to cycle.
     *
     * @param string $name the main name of the daemon such as
     *      MediaUpdater or MailServer
     * @param string $subname instance name when one daemon name can
     *      have several instances
     * @return string absolute path of the origin file
     */
    public static function getOriginFileName($name, $subname = "")
    {
        return C\SCHEDULES_DIR . "/" . self::getNameString($name,
            $subname) . "Origin.txt";
    }
    /**
     * Reads how a daemon was launched, as recorded by the running
     * daemon in its origin file. Returns "terminal" for a
     * foreground run, "daemon" for a background run started through
     * CrawlDaemon::start (e.g. from Manage Machines), or "" when no
     * origin has been recorded (for instance the daemon is not
     * running).
     *
     * @param string $name the main name of the daemon such as
     *      MediaUpdater or MailServer
     * @param string $subname instance name when one daemon name can
     *      have several instances
     * @return string "terminal", "daemon", or "" when unknown
     */
    public static function getOrigin($name, $subname = "")
    {
        $origin_file = self::getOriginFileName($name, $subname);
        if (!file_exists($origin_file)) {
            return "";
        }
        return trim((string) file_get_contents($origin_file));
    }
    /**
     * Used to return a string name for a given daemon instance
     *
     * @param string $name the main name of this daemon such as queue_server
     *     or fetcher.
     * @param string $subname the instance name if it is possible for more
     *     than one copy of the daemon to be running at the same time
     *
     * @return string a single name that combines the name and subname
     */
    public static function getNameString($name, $subname)
    {
        return ($subname === "") ? $name : $subname . "-" . $name;
    }
    /**
     * Removes the lock file for the currently-running daemon (the
     * one whose name/subname were set by init). Lets a daemon that
     * is exiting on its own initiative -- for example because the
     * launcher heartbeat went stale after a `php index.php`
     * web-server shutdown -- clear its lock immediately rather
     * than leaving a stale lock that statuses() keeps reporting as
     * active until PROCESS_TIMEOUT (15 minutes) elapses. Safe to
     * call when no lock is present (it just no-ops) and when name
     * info was never set.
     *
     * @return void
     */
    public static function releaseLock()
    {
        if (empty(self::$name) && empty(self::$subname)) {
            return;
        }
        $lock_file = CrawlDaemon::getLockFileName(self::$name,
            self::$subname);
        if (file_exists($lock_file)) {
            unlink($lock_file);
        }
        $origin_file = CrawlDaemon::getOriginFileName(self::$name,
            self::$subname);
        if (file_exists($origin_file)) {
            unlink($origin_file);
        }
    }
    /**
     * Returns the statuses of the running daemons
     *
     * @return array 2d array active_daemons[name][instance] = true
     */
    public static function statuses()
    {
        $prefix = C\SCHEDULES_DIR . "/";
        $prefix_len = strlen($prefix);
        $suffix = "Lock.txt";
        $suffix_len = strlen($suffix);
        $lock_files = "$prefix*$suffix";
        clearstatcache();
        $time = time();
        $active_daemons = [];
        foreach (glob($lock_files) as $file) {
            if ($time - filemtime($file)  < C\PROCESS_TIMEOUT) {
                if (!self::isProcessAlive(self::lockProcessId($file))) {
                    continue;
                }
                $len = strlen($file) - $suffix_len - $prefix_len;
                $pre_name = substr($file, $prefix_len, $len);
                $pre_name_parts = explode("-", $pre_name);
                if (count($pre_name_parts) == 1) {
                    $active_daemons[$pre_name][-1] = 1;
                } else {
                    $first = array_shift($pre_name_parts);
                    $rest = implode("-", $pre_name_parts);
                    $active_daemons[$rest][$first] = true;
                }
            }
        }
        return $active_daemons;
    }
}
X