<?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";
/**
* CrawlDaemon starts, stops and watches Yioop's background programs,
* such as fetchers, queue servers and the web server itself, through
* lock files and message files on Linux, macOS and Windows.
*
* @author Chris Pollett
*/
class CrawlDaemon implements CrawlConstants
{
/**
* With releaseLock, the daemon running in this process, the one whose
* name and subname init set, takes away its own lock file. It 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.
*/
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);
self::noteLockRemoved(self::getNameString(self::$name,
self::$subname), "this process let its own lock go");
}
$origin_file = CrawlDaemon::getOriginFileName(self::$name,
self::$subname);
if (file_exists($origin_file)) {
unlink($origin_file);
}
}
/**
* drainDaemonStep makes one check, without waiting, of how far a
* stopped daemon has drained, 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 the terminate signal 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);
self::noteLockRemoved(self::getNameString($name, ""),
"it did not stop within the time allowed");
}
return "stopped";
}
clearstatcache(true, $lock_file);
if (!file_exists($lock_file)) {
return "stopped";
}
return "stuck";
}
/**
* SIGNAL_ASK_TO_STOP is the number of the terminate signal, the one
* stopProcess sends on Linux and macOS to ask a process to stop.
*/
const SIGNAL_ASK_TO_STOP = 15;
/**
* SIGNAL_END is the number of the kill signal, the one stopProcess
* sends on Linux and macOS to end a process at once.
*/
const SIGNAL_END = 9;
/**
* What daemonState says of a daemon whose own process is alive.
* @var string
*/
const DAEMON_RUNNING = "running";
/**
* What daemonState says of a daemon started a moment ago that has
* not yet written its own process number.
* @var string
*/
const DAEMON_STARTING = "starting";
/**
* What daemonState says of a daemon that is not there.
* @var string
*/
const DAEMON_STOPPED = "stopped";
/**
* $schedules_folder is the folder CrawlDaemon keeps the daemons' lock,
* origin and message files in, the work directory's schedules folder.
* CrawlDaemonTest sets it to a folder in the tests folder for its
* cases, so they write no file of the site's daemons.
* @var string
*/
public static $schedules_folder = C\SCHEDULES_DIR;
/**
* 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;
/**
* $mode says how this process was run, as a daemon in the background
* or in a terminal, for processHandler to decide how it checks its
* lock
* @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\WORK_DIRECTORY . "/GlobalStop.txt";
/**
* LAUNCH_REQUEST_FOLDER is the folder where Yioop's own web server
* leaves a file for each program it wants started, for the Launcher
* program to start. See handToLauncher.
*/
const LAUNCH_REQUEST_FOLDER = C\WORK_DIRECTORY .
"/schedules/LaunchRequests";
/**
* LAUNCH_REQUEST_ENDING ends the name of each file in
* LAUNCH_REQUEST_FOLDER. A request is written under another name and
* renamed to one with this ending, so the Launcher never reads a
* request that is half written.
*/
const LAUNCH_REQUEST_ENDING = ".launch";
/**
* $index_address is the host and port, as host:port, that a web
* server being started will answer on. index.php sets it from its
* command line before a start, and reportIndexStart connects to it to
* tell whether the server came up. It is empty where nothing is to be
* checked.
* @var string
*/
public static $index_address = "";
/**
* Most seconds a start of the web server waits for the server to
* answer before it says the server did not start
*/
const INDEX_START_SECONDS = 15;
/**
* How many of the last lines of index.log a failed start of the web
* server prints
*/
const INDEX_START_LOG_LINES = 20;
/**
* Microseconds between looks at whether a started web server is up
*/
const INDEX_START_POLL_MICROSECONDS = 250000;
/**
* STOP_ASKED_NAME ends the name of the file in the schedules folder
* where stop writes the time a stop of a daemon was last asked for,
* the daemon's name, such as 0-QueueServer, coming first.
* processHandler stops any process of that daemon that began before
* that time, even when a later start has written a new lock.
*/
const STOP_ASKED_NAME = "StopAsked.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"];
/**
* LISTENING_STATE is how the kernel's socket tables in /proc/net/tcp
* write the state of a socket that waits for connections, in
* hexadecimal. portHolders looks for rows in it.
*/
const LISTENING_STATE = "0A";
/**
* Names of the background daemons the own web server takes down on
* a restart and brings back once it is serving again. A daemon
* belongs here when Manage Machines starts it on the name server and
* a restart should not leave it orphaned: the Media Updater, the
* Mail Server, and the Turn Server that relays calls. The restart
* path reads this in three steps -- recording which are running,
* relaunching each, and naming it in the "brought back up" list --
* so the one list keeps those steps agreeing on the same set.
* @var array
*/
const RESTART_DAEMONS = ["MediaUpdater", "MailServer", "TurnServer"];
/**
* processHandler keeps a daemon's lock file fresh and stops the daemon
* when it should stop. crawlLog calls it on every log line once a
* daemon turns that on, and the queue server and fetcher loops call it
* each pass. On a process's first call, if no lock file is there yet,
* it writes one and returns, since a process that has only just
* started cannot have been told to stop. On every later call it
* rewrites the lock's timestamp, and stops the process if the lock
* file is gone or more than PROCESS_TIMEOUT seconds have passed since
* the last call.
* @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;
static $stop_checked = 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();
$first_check = ($start_time == 0);
if (!empty(self::$name) && self::$mode != 'launcher') {
/* 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);
setWorldPermissions($origin_file);
}
if (self::$mode == 'launcher') {
return true;
}
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);
}
}
/* A stop asked for after this process began is for this process,
whether or not a later start has put a lock back. The file is
read at most once a second, since this runs on every log
line. */
if ($now != $stop_checked) {
$stop_checked = $now;
$name_string = CrawlDaemon::getNameString(self::$name,
self::$subname);
$stop_asked = intval(@file_get_contents(
self::$schedules_folder . "/" .
$name_string . self::STOP_ASKED_NAME));
if ($stop_asked > $start_time) {
crawlLog("Stopping $name_string because a stop was asked " .
"for at " . date("r", $stop_asked) . ", after this " .
"process began at " . date("r", $start_time), null,
true);
exit();
}
}
$lock_exist = file_exists($lock_file);
if (!$lock_exist && $first_check) {
self::writeLockFile($lock_file, $now, true, $start_time);
return true;
}
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) {
$why = (!$lock_exist) ? "its lock file $lock_file is gone" :
"it was told not to continue";
/* How long it had been running is said as well. A
program that has just started and stops again has
been ended by something outside it, and that reads
very differently from one that ran for hours. */
$ran_for = $now - $start_time;
crawlLog("Stopping $name_string because $why ; it had " .
"been running " . $ran_for . " second(s)", null,
true);
exit();
}
}
$time = $now;
self::writeLockFile($lock_file, $now, true, $start_time);
return true;
}
/**
* init reads a program's command line and acts on it: it starts the
* program as a daemon, stops it, sends it a message, or runs it 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));
setWorldPermissions($messages_file);
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));
setWorldPermissions($messages_file);
chmod($messages_file, 0777);
}
exit();
case "start":
/* This process only spawns the daemon and exits; the child
it spawns runs as one. Saying so keeps this process from
writing its own name over the child's in the origin file
and from taking the lock the child holds. */
self::$mode = 'launcher';
$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));
setWorldPermissions($messages_file);
chmod($messages_file, 0777);
}
$_SERVER["LOG_TO_FILES"] = false;
break;
default:
echo $use_message;
exit();
}
}
/**
* daemonLog writes a message about CrawlDaemon's own work where it can
* be read: to standard output while crawlLog is not set up, and to
* crawlLog once it is.
*
* @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)) {
$said = "$name appears to be already running...\n" .
"Try stopping it first (php process_name stop), " .
"then running start.\n";
echo $said;
if (function_exists("seekquarry\\atto\\webExit")) {
\seekquarry\atto\webExit($said);
}
exit();
}
if (file_exists($present_lock)) {
unlink($present_lock);
self::noteLockRemoved(self::getNameString($name,
$tmp_subname),
"a start found the lock with nothing behind it");
}
}
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,
self::startOutput());
if ($name == 'index' && $exit > 0) {
self::reportIndexStart();
}
if ($exit != 0) {
self::writeLockFile($lock_file, time(), false);
}
if ($exit > 0) {
if (function_exists("seekquarry\\atto\\webExit") &&
$name != 'index') {
\seekquarry\atto\webExit();
} else {
exit();
}
}
}
/**
* handToLauncher asks the Launcher program to start $cmd, in place of
* the process this runs in. execInOwnProcess calls it first. A
* program started by Yioop's own web server would hold a copy of
* every file and connection the server had open, which on a busy
* server can be most of what the system lets a program open. The
* Launcher was started before the server took connections, so what it
* starts holds almost nothing. The request is written as a file in
* LAUNCH_REQUEST_FOLDER, under a temporary name first and then renamed,
* so the Launcher never reads half of one. Nothing is handed over
* outside the web server, for a command that is not a Yioop program
* (a restart of the web server itself stays where it is), when no
* Launcher is running, or when the Launcher runs as a user other than
* this process's, as it does for the root supervisor; the caller then
* starts the command itself.
*
* @param string $cmd the command to start
* @param string $output where the command's output is to go
* @return bool whether the Launcher was asked to start the command
*/
public static function handToLauncher($cmd, $output)
{
if (!C\nsdefined("IS_OWN_WEB_SERVER") ||
!self::isLaunchableCommand($cmd) ||
!self::lockHeldByLiveProcess(
self::getLockFileName("Launcher", ""))) {
return false;
}
if (!self::launcherSharesUser(self::lockProcessId(
self::getLockFileName("Launcher", "")), C\WWW_USER)) {
return false;
}
$request_name = self::LAUNCH_REQUEST_FOLDER . "/" .
sprintf("%.6F", microtime(true)) . "-" . mt_rand();
if (@file_put_contents("$request_name.writing", json_encode(
["command" => $cmd, "output" => $output])) === false) {
return false;
}
return rename("$request_name.writing",
$request_name . self::LAUNCH_REQUEST_ENDING);
}
/**
* isLaunchableCommand says whether $cmd starts one of the programs in
* Yioop's executables folder, the only commands the Launcher runs. A
* command holding a character a shell would read as joining, piping,
* substituting or redirecting commands is refused. handToLauncher
* calls it before writing a request and the Launcher again before
* starting one.
*
* @param string $cmd the command to check
* @return bool whether the command starts a Yioop program
*/
public static function isLaunchableCommand($cmd)
{
$executables = [C\BASE_DIR . "/executables/",
str_replace("/", "\\", C\BASE_DIR) . "\\executables\\"];
$names_program = false;
foreach ($executables as $folder) {
if (preg_match("/" . preg_quote($folder, "/") .
"[A-Za-z]+\.php/", $cmd)) {
$names_program = true;
}
}
return $names_program && !preg_match('/[;&|`$<>\r\n]/', $cmd);
}
/**
* execInOwnProcess starts a command as a background process of its
* own, one that goes on after this process ends.
*
* 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. Inside Yioop's
* own web server, a Yioop program is handed to the Launcher instead,
* as handToLauncher says, so it holds none of the server's files and
* connections.
*
* 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')
{
if (self::handToLauncher($cmd, $output)) {
return;
}
$is_windows = (strstr(PHP_OS, "WIN") !== false);
if (!$is_windows &&
(function_exists("pcntl_fork") &&
function_exists("pcntl_exec") &&
function_exists("posix_setsid"))) {
self::forkLaunch($cmd, $output);
return;
}
$job = self::composeBackgroundJob($cmd, $output, $is_windows);
pclose(popen($job, "r"));
}
/**
* stopProcess ends the process with id $process_id on Linux, macOS or
* Windows. With $ask_first, on Linux and macOS it sends SIGTERM, so a
* process that catches it can log or tidy up before it exits; without
* it, or on Windows, where a console process cannot be asked, the
* process is ended at once, with SIGKILL or taskkill /F, as a crash
* would. Unit tests call it to stop a process they started with
* execInOwnProcess part way through a step, and the web server's front
* calls it, through its WORKER_STOP option, to stop a worker that ran
* too long.
*
* @param int $process_id id of the process to stop
* @param bool $ask_first whether to ask the process to stop rather
* than end it at once, where the system allows
*/
public static function stopProcess($process_id, $ask_first = false)
{
$process_id = intval($process_id);
if ($process_id <= 0) {
return;
}
if (strstr(PHP_OS, "WIN") !== false) {
exec("taskkill /F /PID $process_id 2>NUL");
} else if (function_exists("posix_kill")) {
posix_kill($process_id, $ask_first ? self::SIGNAL_ASK_TO_STOP :
self::SIGNAL_END);
}
}
/**
* forkLaunch is how execInOwnProcess starts $cmd where the system can
* fork.
*
* 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 >> " .
escapeshellarg($output) . " 2>&1"]);
self::endForkedHelper();
}
/**
* endForkedHelper ends one of forkLaunch's short-lived helper
* processes at once, with no run of 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);
}
/**
* portHolders names the processes holding a listening socket on
* $port, by process id with the command each was started with. The
* supervisor logs them when a web server it starts does not come up,
* so a port kept open by another process is named in index.log. It
* reads the kernel's socket tables and each process's open files in
* /proc, which only Linux has; elsewhere it finds no one. Run as
* root, it can read the open files of every process.
*
* @param int $port the port to look up
* @return array process id => command line of each process found
*/
public static function portHolders($port)
{
$socket_ids = [];
foreach (["/proc/net/tcp", "/proc/net/tcp6"] as $table) {
$rows = is_readable($table) ? file($table) : [];
foreach (array_slice($rows, 1) as $row) {
$fields = preg_split('/\s+/', trim($row));
$local_port = hexdec(substr(strrchr($fields[1], ":"), 1));
if ($local_port == $port &&
$fields[3] == self::LISTENING_STATE) {
$socket_ids[] = "socket:[" . $fields[9] . "]";
}
}
}
$holders = [];
if ($socket_ids == []) {
return $holders;
}
foreach (glob("/proc/[0-9]*/fd/*") ?: [] as $descriptor) {
if (in_array(@readlink($descriptor), $socket_ids, true)) {
$process_id = intval(substr($descriptor, strlen("/proc/")));
$holders[$process_id] = trim(str_replace("\0", " ",
(string)@file_get_contents("/proc/$process_id/cmdline")));
}
}
return $holders;
}
/**
* closeInheritedListeningSockets is run in a fork about to become
* another program, and closes the listening sockets the fork
* inherited, so the 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);
}
}
}
/**
* composeBackgroundJob writes the shell command line that runs $cmd
* in the background, reading nothing and adding its output 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 >> " . escapeshellarg($output) .
" 2>&1 &";
}
/**
* launcherSharesUser says whether the Launcher, whose process id is
* $launcher_id, runs as the user this process runs as. A program the
* Launcher starts runs as the Launcher's user, so handToLauncher
* hands a program over only then: the root supervisor starts a mail
* server, which binds ports below 1024, itself. A process that is not
* root can signal only processes of its own user, so signal 0, which
* sends nothing, tells it. Root can signal any process; the Launcher
* keeps root only where there is no WWW_USER for it to become. Where
* there are no user ids, as on Windows, every caller shares the
* Launcher's user.
*
* @param int $launcher_id process id the Launcher's lock records
* @param string $www_user the user the Launcher becomes when started
* as root, WWW_USER
* @return bool whether a program may be handed to the Launcher
*/
public static function launcherSharesUser($launcher_id, $www_user)
{
if (!function_exists("posix_geteuid")) {
return true;
}
if (posix_geteuid() === 0) {
return $www_user == "";
}
return $launcher_id > 0 && posix_kill($launcher_id, 0);
}
/**
* startOutput names the file a program start sends the program's own
* output to: index.log, so a program that fails before it writes a
* log line of its own, such as a php that does not load, says why
* where the web server's lines are. Before Yioop is configured there
* is no log folder, and the output is dropped.
*
* @return string the file the started program's output is added to
*/
public static function startOutput()
{
if (strstr(PHP_OS, "WIN")) {
return "NUL";
}
if (!C\PROFILE || !file_exists(C\LOG_DIR)) {
return "/dev/null";
}
return C\LOG_DIR . "/index.log";
}
/**
* reportIndexStart waits, after a start of the web server, up to
* INDEX_START_SECONDS for the server to answer on $index_address.
* When it does not, it says the server did not start and prints the
* end of index.log, where the started program's own output goes, so a
* start that fails does not end in silence.
*
* @return bool whether the server came up, true where there is no
* address to check
*/
public static function reportIndexStart()
{
if (!C\PROFILE || self::$index_address == "") {
return true;
}
$up = self::indexServerUp(self::$index_address,
self::INDEX_START_SECONDS);
if (!$up) {
echo "The web server did not start within " .
self::INDEX_START_SECONDS . " seconds.\n";
$log_file = C\LOG_DIR . "/index.log";
if (file_exists($log_file)) {
echo "The end of $log_file:\n";
$lines = file($log_file, FILE_IGNORE_NEW_LINES) ?: [];
echo implode("\n", array_slice($lines,
-self::INDEX_START_LOG_LINES)) . "\n";
}
}
return $up;
}
/**
* indexServerUp says whether something answers a connection to
* $address, given as host:port, within $seconds. A start of the web
* server asks it about the address the server will listen on; no
* server was answering there before the start, since a start is
* refused while one runs.
*
* @param string $address host and port to connect to, as host:port
* @param float $seconds how long to keep trying
* @param int $poll_microseconds microseconds between tries; by
* default INDEX_START_POLL_MICROSECONDS
* @return bool whether a connection was taken in that time
*/
public static function indexServerUp($address, $seconds,
$poll_microseconds = self::INDEX_START_POLL_MICROSECONDS)
{
$give_up = microtime(true) + $seconds;
do {
$link = @stream_socket_client("tcp://$address", $error_number,
$error_text, 1);
if ($link !== false) {
fclose($link);
return true;
}
usleep($poll_microseconds);
} while (microtime(true) < $give_up);
return false;
}
/**
* execScriptInOwnProcess runs a php script as a background process of
* its own, with the php running this program unless PHP_PATH names
* another.
* 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);
}
} else if (PHP_BINARY != "" && php_sapi_name() == "cli" &&
!strstr(PHP_OS, "WIN")) {
/* The program is run by the PHP running this one, not by
whichever php the shell's path finds first. Those can
differ, as after a move from one Homebrew to another, and a
php that fails to load wrote its reason to the launch's
output, where no one saw it. */
$php = escapeshellarg(PHP_BINARY);
}
/* 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);
}
/**
* mayInterruptOnStop says whether a stop of the named daemon may cut
* it off 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");
}
/**
* signalDaemonStop signals the named daemon to stop 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);
}
}
/**
* lockWrittenAfter says whether a lock file was written later than
* a given moment. A caller uses it to tell a daemon it knows about
* from one started since, so that a step begun a while ago does not
* act on something younger than itself.
*
* @param string $lock_file absolute path of the lock file to read
* @param int $moment time to compare the lock's own time against
* @return bool true when the lock exists and was written after that
* moment
*/
public static function lockWrittenAfter($lock_file, $moment)
{
clearstatcache(true, $lock_file);
if (!file_exists($lock_file)) {
return false;
}
$fields = explode(" ", trim((string)file_get_contents(
$lock_file)));
/* The third field is the time the daemon itself began. The
first is the time the lock was last written, which a running
daemon refreshes every few seconds, so reading that instead
would call every daemon younger than any moment asked about.
A lock with no third field is read by its first, which is as
much as is known of a daemon that has just been started. */
$began = isset($fields[2]) ? intval($fields[2]) :
intval($fields[0]);
return $began > $moment;
}
/**
* noteLockRemoved writes down that a daemon's lock file has just
* been taken away, and which process took it. A daemon stops as
* soon as its lock is gone, so a daemon that dies for no clear
* reason is a daemon whose lock someone removed; without a line
* saying who, the daemon's own log shows the death and nothing
* about the cause. The names of the methods that led here are
* written too, since the same removal is reached from a stop asked
* for by a person, from a restart, and from a wait for a daemon to
* go.
*
* @param string $name_string name of the daemon whose lock was
* removed, with its instance where it has one
* @param string $why short words saying which step removed it
*/
public static function noteLockRemoved($name_string, $why)
{
$steps = [];
foreach (debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 6) as
$frame) {
if (empty($frame["function"])) {
continue;
}
$steps[] = ($frame["class"] ?? "") .
($frame["type"] ?? "") . $frame["function"];
}
$process_id = function_exists("getmypid") ? getmypid() : 0;
crawlLog("Lock for " . $name_string . " removed by process " .
$process_id . " (" . $why . "), reached through: " .
implode(" < ", $steps));
}
/**
* 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)
* @param int $asked_at time this stop was asked for, so that a
* daemon started since is left alone; 0 where the caller has
* no such moment in mind and every daemon found is stopped
*/
public static function stop($name, $subname = "", $exit = true,
$asked_at = 0)
{
$name_string = CrawlDaemon::getNameString($name, $subname);
$lock_file = CrawlDaemon::getLockFileName($name, $subname);
/* A stop belongs to the moment it was asked for. Stopping a
whole site takes long enough that another part of it can have
started the same daemon again in the meantime, and this stop
would then end a daemon younger than itself. A lock written
after the stop was asked for therefore belongs to a daemon
this stop has nothing to do with, and is left alone. */
if ($asked_at > 0 && self::lockWrittenAfter($lock_file,
$asked_at)) {
crawlLog("Leaving " . $name_string . " alone: it was " .
"started after this stop was asked for");
return;
}
$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);
}
}
/* Removing the lock is how a daemon is told to stop, but a
daemon only looks at its lock between steps, and two processes,
as a queue server's Indexer and Scheduler, share one lock. A
daemon started again under the same name writes a new lock, and
a process of the old run still inside a long step then finds a
lock and carries on. So the time of the stop is also kept in a
file of its own, which processHandler compares with the time
its process began. */
if (file_exists($lock_file)) {
self::signalDaemonStop($lock_file, $name);
unlink($lock_file);
file_put_contents(self::$schedules_folder . "/" . $name_string .
self::STOP_ASKED_NAME, time());
self::noteLockRemoved($name_string, "a stop was asked for");
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);
file_put_contents(self::$schedules_folder . "/" .
CrawlDaemon::getNameString($name, "0") .
self::STOP_ASKED_NAME, time());
self::noteLockRemoved($name_string,
"a stop was asked for");
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());
setWorldPermissions($global_stop_file);
chmod($global_stop_file, 0777);
exit();
}
if (function_exists("seekquarry\\atto\\webExit")) {
\seekquarry\atto\webExit();
} else {
exit();
}
}
}
/**
* getMessageFileName gives the path of the file through which
* messages are passed 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 self::$schedules_folder . "/" .
self::getNameString($name, $subname) . "Messages.txt";
}
/**
* getLockFileName gives the path of a daemon's lock file, the file
* whose presence and time say the daemon is running
*
* @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 self::$schedules_folder . "/" .
self::getNameString($name, $subname) . "Lock.txt";
}
/**
* writeLockFile puts down the file that says a daemon is running.
* The file holds the time it was written, and after a space the
* number of the process the file speaks for, where that number is
* known. A reader wanting only the time reads the number out of
* the way for free, since reading a whole number stops at the
* space. A reader wanting to know whether the daemon is still
* there asks the system about that process number, which answers
* at once rather than waiting for the time to go stale.
*
* @param string $lock_file absolute path of the lock file to write
* @param int $now timestamp to record as the lock heartbeat
* @param bool $own_process true when the lock belongs to this
* process, false when it belongs to a program this process is
* starting and whose own id is not known yet
* @param int $began time the daemon itself began, written as a
* third field so a reader can tell a daemon apart from one
* started later; left out where it is not known
*/
public static function writeLockFile($lock_file, $now,
$own_process = true, $began = 0)
{
$process_id = function_exists("getmypid") ? getmypid() : false;
if (!$own_process) {
/* The lock belongs to a program this process is only
starting, whose own id is not known yet. Recording this
process's id instead names the wrong process: a reader
asking whether the daemon has stopped is told about the
process that started it, which is still running, so it
waits for a daemon that has already gone and may signal
the starter by mistake. A lock with no id is read by its
time instead, until the daemon writes its own. */
$process_id = false;
}
$contents = ($process_id === false) ? (string)$now :
$now . " " . $process_id;
if ($began > 0 && $process_id !== false) {
$contents .= " " . $began;
}
file_put_contents($lock_file, $contents);
setWorldPermissions($lock_file);
}
/**
* lockProcessId gives the process id a daemon lock file records, if
* it records one.
* 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]);
}
/**
* lockHeldByLiveProcess says whether the process that holds a daemon
* lock file is 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;
}
/**
* isProcessAlive says whether the process with the given id is still
* running. A start asks it about the process a lock file names, so a
* lock left by a server or daemon that ended with Ctrl-C does not
* stop the next start. On Linux and macOS it asks with posix_kill and
* signal 0, which sends nothing. Windows has no such call, so there
* it looks for the id among the processes the tasklist program lists
* (see windowsProcessIds). Where neither way can answer, it says the
* process is running, so a live daemon is never taken for 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 (strstr(PHP_OS, "WIN")) {
$running = self::windowsProcessIds();
return ($running === null) ? true : isset($running[$process_id]);
}
if (!function_exists("posix_kill")) {
return true;
}
if (posix_kill($process_id, 0)) {
/* A process that has ended but whose parent has not yet
collected it, a zombie, still takes signals. On Linux its
state in /proc is Z; a process started with
execInOwnProcess belongs to the system's first process,
which in a container may never collect it. Elsewhere that
process collects it at once. */
$stat = @file_get_contents("/proc/$process_id/stat");
return !($stat && preg_match('/\)\s+Z\s/', $stat));
}
/* 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;
}
/**
* windowsProcessIds gives the ids of the processes running on a
* Windows machine, as the tasklist program that ships with Windows
* lists them, for isProcessAlive. tasklist takes a moment to run and
* a page such as Manage Machines asks about every lock file, so the
* list is kept for the rest of the second it was read in. Lines are
* read as tasklist writes them with /FO CSV /NH: one quoted,
* comma-separated row per process, the program's name first and its
* id second, and no heading row.
*
* @param array $lines rows to read instead of running tasklist, as
* a test passes them
* @return array|null process id => true for each process listed, or
* null when tasklist could not be run or listed nothing
*/
public static function windowsProcessIds($lines = null)
{
static $held = [-1, null];
$from_tasklist = ($lines === null);
if ($from_tasklist) {
if ($held[0] == time()) {
return $held[1];
}
$lines = [];
if (function_exists("exec")) {
exec("tasklist /FO CSV /NH 2> NUL", $lines);
}
}
$process_ids = [];
foreach ($lines as $line) {
$fields = str_getcsv($line, escape: "");
if (isset($fields[1]) && ctype_digit($fields[1])) {
$process_ids[intval($fields[1])] = true;
}
}
$process_ids = empty($process_ids) ? null : $process_ids;
if ($from_tasklist) {
$held = [time(), $process_ids];
}
return $process_ids;
}
/**
* saveDaemonStateForRestart takes down the background services this
* web server started, and writes down which ones to bring back once
* the replacement server is up. Those are the Media Updater, the
* Mail Server and the Turn Server. One a person started in their
* own terminal is left running and not written down, since nobody
* asked this server to manage it. The admin Restart Server action
* and a restart from the command line both come here, so the two
* take the services down the same way.
*
* @param int $asked_at time the restart was asked for, passed on so
* that taking the services down cannot end one started since
* that moment; the present moment is used where none is given
*/
public static function saveDaemonStateForRestart($asked_at = 0)
{
if (!defined("seekquarry\\yioop\\configs\\IS_OWN_WEB_SERVER")) {
return;
}
if ($asked_at <= 0) {
$asked_at = time();
}
$statuses = self::statuses();
$to_relaunch = [];
foreach (self::RESTART_DAEMONS 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 . " " .
$asked_at;
self::stop($daemon, "", false, $asked_at);
}
$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));
setWorldPermissions($marker);
}
/**
* setRestartStatus writes down which phase a restart is in, for the
* Restart Server page to show. 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 = (C\DATA_DIR . "/RestartStatus.txt");
if ($phase === "") {
clearstatcache(true, $status_file);
if (file_exists($status_file)) {
unlink($status_file);
}
return;
}
file_put_contents($status_file, $phase);
setWorldPermissions($status_file);
}
/**
* getRestartStatus gives the phase a restart is in, or the empty
* string when no restart is going on.
*
* @return string the recorded phase, or "" when none is set
*/
public static function getRestartStatus()
{
$status_file = (C\DATA_DIR . "/RestartStatus.txt");
clearstatcache(true, $status_file);
if (!file_exists($status_file)) {
return "";
}
return trim((string)file_get_contents($status_file));
}
/**
* getOriginFileName gives the path of the file that says how a daemon
* was started ("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 self::$schedules_folder . "/" . self::getNameString($name,
$subname) . "Origin.txt";
}
/**
* getOrigin gives how a daemon was started, as the running daemon
* wrote it 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));
}
/**
* getNameString gives the name a daemon instance goes by, its name
* and subname put together
*
* @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;
}
/**
* isDaemonRunning says whether a named daemon is running now, by
* the id it records in its own lock file. A lock holding no id is
* one a starting process wrote before the daemon had begun, so a
* daemon that died on startup leaves such a lock behind and looks
* alive for as long as that lock is fresh. A caller that has to
* know whether a service really came back asks this rather than
* reading the list of locks.
*
* @param string $name name of the daemon, such as MailServer
* @param string $subname instance name where one daemon name can
* have several instances, or the empty string
* @return bool true when the daemon has written its own id and
* that process is alive
*/
public static function isDaemonRunning($name, $subname = "")
{
return self::daemonState($name, $subname) == self::DAEMON_RUNNING;
}
/**
* daemonState says which of three states a named daemon is in, read
* from its lock file and without waiting for anything. A daemon
* writes its own process number into that file a moment after it
* begins, so one that has been started but has not got there yet
* holds a lock with a time and no number. Telling that apart from a
* daemon that never started matters to a reader: the first needs
* another moment and the second needs looking into.
*
* @param string $name name of the daemon, such as MailServer
* @param string $subname instance name where one daemon name can
* have several instances, or the empty string
* @return string DAEMON_RUNNING where the daemon has written its
* own number and that process is alive, DAEMON_STARTING where
* it was started a moment ago and has not written one yet,
* DAEMON_STOPPED otherwise
*/
public static function daemonState($name, $subname = "")
{
$lock_file = self::getLockFileName($name, $subname);
clearstatcache(true, $lock_file);
if (!file_exists($lock_file)) {
return self::DAEMON_STOPPED;
}
$process_id = self::lockProcessId($lock_file);
if ($process_id > 0) {
return self::isProcessAlive($process_id) ?
self::DAEMON_RUNNING : self::DAEMON_STOPPED;
}
$written = intval(file_get_contents($lock_file));
if (time() - $written <= C\PROCESS_TIMEOUT) {
return self::DAEMON_STARTING;
}
return self::DAEMON_STOPPED;
}
/**
* Returns the statuses of the running daemons
*
* @return array 2d array active_daemons[name][instance] = true
*/
public static function statuses()
{
$prefix = self::$schedules_folder . "/";
$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;
}
}