<?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;
}
}