├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── composer.lock ├── example.php ├── examples ├── GearmanClientExample.php ├── GearmanWorker.ini └── GearmanWorkerExample.php └── src ├── Daemon.php ├── GearmanWorkerManager.php └── ProcessManager.php /.gitignore: -------------------------------------------------------------------------------- 1 | pid 2 | log 3 | log.err 4 | vendor/ 5 | .*.swp 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2012-2014 Eric Stern 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Process Control Tools 2 | 3 | ## Requirements 4 | * `posix` and `pcntl` extensions 5 | * PHP5.4+ (Uses modern syntax) 6 | * Basic knowledge of PHP on the command line 7 | 8 | # Daemon 9 | A really useful tool with a very boring name. Daemonize your PHP scripts with two lines of code. 10 | 11 | 12 | ## Usage 13 | Code: 14 | 15 | setUser('sites') 21 | ->setPidFileLocation('/var/run/gearman-manager2.pid') 22 | ->setStdoutFileLocation(sys_get_temp_dir().'/my.log') 23 | ->setStdErrFileLocation('/dev/null') 24 | ->setProcessName(basename(__FILE__).' master process') 25 | ->autoRun(); 26 | // The rest of your original script 27 | 28 | CLI: 29 | 30 | php yourscript.php {status|start|stop|restart|reload|kill} 31 | 32 | Yes, it's that simple. 33 | 34 | ### Actions 35 | * Status: Check the status of the process. Returns: 36 | * 0 if running 37 | * 1 if dead but pidfile is hanging around 38 | * 3 if stopped 39 | * Start: Start the daemon 40 | * Stop: Stop the daemon gracefully via SIGTERM 41 | * Restart: Stop (if running) and start 42 | * Reload: Send SIGUSR1 to daemon (you need to implement a reload function, see below) 43 | * Kill: Kill the daemon via SIGKILL (kill -9) 44 | 45 | ## Options 46 | * `setProcessName($string)`: Set the process name as it will appear in utilities such as `top`. This is only supported under PHP5.5+. 47 | * `setPidFileLocation($path)`: Specify the location of the pid file. This file stores the process id when the daemon is running, and goes away when the daemon stops. 48 | * `setStdoutFileLocation($path)`: File where anything that would have been written to `STDOUT` (`echo`, `print`, etc) goes. 49 | * `setStdErrFileLocation($path)`: File where anything that would have been written to `STDERR` goes. It appears that `display_errors` no longer writes to `STDERR` after daemonizing, so setting this to `/dev/null` is pretty safe. 50 | * `setUser($system_user)`: If you want to have the process run as a lower-security user, specify the username here. This is especially helpful if you start the daemon on system with `chkconfig` and `/etc/init.d`, since those run as root. 51 | 52 | To come later(?): 53 | * Verbose output 54 | * Synchronous mode (do not daemonize for debugging) 55 | * Log file configuration 56 | 57 | ## Useful tips 58 | 59 | * STDOUT (echo, print) is redirected to the log file. 60 | * The "reload" command won't do anything without installing a handler for SIGUSR1. Examples are due shortly. 61 | 62 | 63 | ## Known Issues 64 | 65 | * STDERR doesn't appear to go anywhere, despite opening a logfile for it. 66 | * The script can't set up "reload" bindings automatically. This is a PHP limitation: "The declare construct can also be used in the global scope, affecting all code following it (**however if the file with declare was included then it does not affect the parent file**)". [http://docs.php.net/manual/en/control-structures.declare.php]() 67 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { "name": "firehed/processmanager" 2 | , "description": "Simple base for daemonized wokers" 3 | , "license": "MIT" 4 | , "autoload": 5 | { "psr-4": 6 | { "Firehed\\ProcessControl\\": "src/" 7 | } 8 | } 9 | , "require": 10 | { "psr/log": ">=1.0.0" 11 | , "php": ">=5.4.0" 12 | , "ext-posix": "*" 13 | , "ext-pcntl": "*" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /composer.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_readme": [ 3 | "This file locks the dependencies of your project to a known state", 4 | "Read more about it at http://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file" 5 | ], 6 | "hash": "7007c627abaf3a6e983fdc7c16131e36", 7 | "packages": [ 8 | { 9 | "name": "firehed/daemon", 10 | "version": "dev-master", 11 | "source": { 12 | "type": "git", 13 | "url": "https://github.com/Firehed/php-daemon.git", 14 | "reference": "4ec7fdf8813b13d7c89eea2437639495876b4827" 15 | }, 16 | "dist": { 17 | "type": "zip", 18 | "url": "https://api.github.com/repos/Firehed/php-daemon/zipball/4ec7fdf8813b13d7c89eea2437639495876b4827", 19 | "reference": "4ec7fdf8813b13d7c89eea2437639495876b4827", 20 | "shasum": "" 21 | }, 22 | "require": { 23 | "ext-pcntl": "*", 24 | "ext-posix": "*", 25 | "php": ">=5.3.0" 26 | }, 27 | "type": "library", 28 | "autoload": { 29 | "psr-0": { 30 | "Firehed\\ProcessControl\\Daemon": "src/" 31 | } 32 | }, 33 | "notification-url": "https://packagist.org/downloads/", 34 | "license": [ 35 | "MIT" 36 | ], 37 | "description": "Turn a command-line script into a daemonized process", 38 | "time": "2013-07-10 08:27:27" 39 | }, 40 | { 41 | "name": "psr/log", 42 | "version": "1.0.0", 43 | "source": { 44 | "type": "git", 45 | "url": "https://github.com/php-fig/log", 46 | "reference": "1.0.0" 47 | }, 48 | "dist": { 49 | "type": "zip", 50 | "url": "https://github.com/php-fig/log/archive/1.0.0.zip", 51 | "reference": "1.0.0", 52 | "shasum": "" 53 | }, 54 | "type": "library", 55 | "autoload": { 56 | "psr-0": { 57 | "Psr\\Log\\": "" 58 | } 59 | }, 60 | "notification-url": "https://packagist.org/downloads/", 61 | "license": [ 62 | "MIT" 63 | ], 64 | "authors": [ 65 | { 66 | "name": "PHP-FIG", 67 | "homepage": "http://www.php-fig.org/" 68 | } 69 | ], 70 | "description": "Common interface for logging libraries", 71 | "keywords": [ 72 | "log", 73 | "psr", 74 | "psr-3" 75 | ], 76 | "time": "2012-12-21 11:40:51" 77 | } 78 | ], 79 | "packages-dev": [ 80 | 81 | ], 82 | "aliases": [ 83 | 84 | ], 85 | "minimum-stability": "stable", 86 | "stability-flags": { 87 | "firehed/daemon": 20 88 | }, 89 | "platform": [ 90 | 91 | ], 92 | "platform-dev": [ 93 | 94 | ] 95 | } 96 | -------------------------------------------------------------------------------- /example.php: -------------------------------------------------------------------------------- 1 | addServer(); 9 | 10 | echo "Sending a 'flip_it' job to gearman...\n"; 11 | echo "Returned: "; 12 | echo $client->doNormal("flip_it", "Hello World!"); 13 | echo "\n"; 14 | 15 | echo "Sending a 'my_uppercase' job to gearman...\n"; 16 | echo "Returned: "; 17 | echo $client->doNormal('my_uppercase', 'some lowercase string'); 18 | echo "\n"; 19 | 20 | -------------------------------------------------------------------------------- /examples/GearmanWorker.ini: -------------------------------------------------------------------------------- 1 | [yeller] 2 | count=2 3 | functions[]=my_uppercase 4 | 5 | [mirror] 6 | count=3 7 | functions[]=flip_it 8 | -------------------------------------------------------------------------------- /examples/GearmanWorkerExample.php: -------------------------------------------------------------------------------- 1 | workload()); 13 | } 14 | 15 | // Required example code: set up ticks (or process control signals can't 16 | // register) 17 | declare(ticks=1); 18 | 19 | // Suggestion: use Firehed\ProcessControl\Daemon to daemonize the master worker 20 | // Available via composer at firehed/daemon 21 | 22 | // Get a new worker manager for configuration 23 | $pm = new Firehed\ProcessControl\GearmanWorkerManager; 24 | 25 | // Example GearmanWorkerManager class jobs 26 | // 27 | // Param 1 is the job name (corresponds to param 1 of GearmanClient::doX class 28 | // of functions) 29 | // 30 | // Param 2 is the callable to invoke when such a job is received - anything 31 | // callable with "call_user_func" should work. The function must accept one 32 | // parameter: GearmanJob $job. This means native PHP funtions must be wrapped: 33 | // see 'my_reverse_function' 34 | $pm->registerFunction("flip_it", "my_reverse_function"); 35 | 36 | $pm->registerFunction("my_uppercase", function(GearmanJob $job) { 37 | return strtoupper($job->workload()); 38 | }); 39 | 40 | // Once GM functions have been registered, load a config mapping worker name 41 | // to count and functions to run 42 | $pm->setConfigFile(__DIR__.'/GearmanWorker.ini'); 43 | 44 | // When other configuration options are available, examples will be added here 45 | 46 | // ProcessManager requirement: start working 47 | $pm->start(); 48 | 49 | -------------------------------------------------------------------------------- /src/Daemon.php: -------------------------------------------------------------------------------- 1 | didTick = true; 41 | } 42 | 43 | private function checkForDeclareDirective() { 44 | // PHP7 appears to exhibit different behavior with ticks than 45 | // 5. Basically, >=7 requires the tick handler at the top of this 46 | // file for this to execute at all (making the detection 47 | // pointless), but it doesn't persist in the rest of the script. 48 | if (version_compare(\PHP_VERSION, '7.0.0', '>=')) { 49 | return; 50 | } 51 | register_tick_function([$this, 'didTick']); 52 | usleep(1); 53 | if (!$this->didTick) { 54 | // Try a bunch of no-ops in case the directive is set as > 1 55 | $i = 1000; 56 | while ($i--); 57 | } 58 | unregister_tick_function([$this, 'didTick']); 59 | if (!$this->didTick) { 60 | fwrite(STDERR, "It looks like `declare(ticks=1);` has not been ". 61 | "called, so signals to stop the daemon will fail. Ensure ". 62 | "that the root-level script calls this.\n"); 63 | exit(1); 64 | } 65 | } 66 | 67 | public function __construct() { 68 | if (self::$instance) { 69 | self::crash("Singletons only, please"); 70 | } 71 | self::$instance = $this; // avoid premature destruct 72 | 73 | // parse options 74 | $this->checkForDeclareDirective(); 75 | } 76 | 77 | public function setUser($systemUsername) { 78 | $info = posix_getpwnam($systemUsername); 79 | if (!$info) { 80 | self::crash("User '$systemUsername' not found"); 81 | } 82 | $this->userId = $info['uid']; 83 | return $this; 84 | } 85 | 86 | public function setProcessName($name) { 87 | if (function_exists('cli_set_process_title')) { 88 | cli_set_process_title($name); 89 | } 90 | return $this; 91 | } 92 | 93 | public function setPidFileLocation($path) { 94 | if (!is_string($path)) { 95 | throw new InvalidArgumentException("Pidfile path must be a string"); 96 | } 97 | $this->pidfile = $path; 98 | return $this; 99 | } 100 | 101 | public function setStdoutFileLocation($path) { 102 | if (!is_string($path)) { 103 | throw new InvalidArgumentException("Stdout path must be a string"); 104 | } 105 | $this->logFile = $path; 106 | return $this; 107 | } 108 | 109 | public function setStderrFileLocation($path) { 110 | if (!is_string($path)) { 111 | throw new InvalidArgumentException("Stderr path must be a string"); 112 | } 113 | $this->errFile = $path; 114 | return $this; 115 | } 116 | 117 | public function setTerminateLimit($seconds) { 118 | if (!is_int($seconds) || $seconds < 1) { 119 | throw new InvalidArgumentException("Limit must be a positive int"); 120 | } 121 | $this->termLimit = $seconds; 122 | return $this; 123 | } 124 | 125 | public function autoRun() { 126 | if ($_SERVER['argc'] < 2) { 127 | self::showHelp(); 128 | } 129 | $cmd = strtolower(end($_SERVER['argv'])); 130 | switch ($cmd) { 131 | case 'start': 132 | case 'stop': 133 | case 'restart': 134 | case 'reload': 135 | case 'status': 136 | case 'kill': 137 | call_user_func(array($this, $cmd)); 138 | break; 139 | default: 140 | self::showHelp(); 141 | break; 142 | } 143 | } 144 | 145 | private function start() { 146 | self::show("Starting..."); 147 | // Open and lock PID file 148 | $this->fh = fopen($this->pidfile, 'c+'); 149 | if (!flock($this->fh, LOCK_EX | LOCK_NB)) { 150 | self::crash("Could not lock the pidfile. This daemon may already ". 151 | "be running."); 152 | } 153 | 154 | // Fork 155 | $this->debug("About to fork"); 156 | $pid = pcntl_fork(); 157 | switch ($pid) { 158 | case -1: // fork failed 159 | self::crash("Could not fork"); 160 | break; 161 | 162 | case 0: // i'm the child 163 | $this->childPid = getmypid(); 164 | $this->debug("Forked - child process ($this->childPid)"); 165 | break; 166 | 167 | default: // i'm the parent 168 | $me = getmypid(); 169 | $this->debug("Forked - parent process ($me -> $pid)"); 170 | fseek($this->fh, 0); 171 | ftruncate($this->fh, 0); 172 | fwrite($this->fh, $pid); 173 | fflush($this->fh); 174 | $this->debug("Parent wrote PID"); 175 | exit; 176 | } 177 | 178 | // detatch from terminal 179 | if (posix_setsid() === -1) { 180 | self::crash("Child process could not detach from terminal."); 181 | } 182 | if (null !== $this->userId) { 183 | if (!posix_setuid($this->userId)) { 184 | self::crash("Could not change user. Try running this program". 185 | " as root."); 186 | } 187 | } 188 | 189 | self::ok(); 190 | // stdin/etc reset 191 | $this->debug("Resetting file descriptors"); 192 | fclose(STDIN); 193 | fclose(STDOUT); 194 | fclose(STDERR); 195 | $this->stdin = fopen('/dev/null', 'r'); 196 | $this->stdout = fopen($this->logFile, 'a+'); 197 | $this->stderr = fopen($this->errFile, 'a+'); 198 | $this->debug("Reopened file descriptors"); 199 | $this->debug("Executing original script"); 200 | pcntl_signal(SIGTERM, function() { exit; }); 201 | } 202 | 203 | private function terminate($msg, $signal) { 204 | self::show($msg); 205 | $pid = $this->getChildPid(); 206 | if (false === $pid) { 207 | self::failed(); 208 | echo "No PID file found\n"; 209 | return; 210 | } 211 | if (!posix_kill($pid, $signal)) { 212 | self::failed(); 213 | echo "Process $pid not running!\n"; 214 | return; 215 | } 216 | $i = 0; 217 | while (posix_kill($pid, 0)) { // Wait until the child goes away 218 | if (++$i >= $this->termLimit) { 219 | self::crash("Process $pid did not terminate after $i seconds"); 220 | } 221 | self::show('.'); 222 | sleep(1); 223 | } 224 | self::ok(); 225 | } 226 | 227 | public function __destruct() { 228 | if (getmypid() == $this->childPid) { 229 | unlink($this->pidfile); 230 | } 231 | } 232 | 233 | private function stop($exit = true) { 234 | $this->terminate('Stopping', SIGTERM); 235 | $exit && exit; 236 | } 237 | private function restart() { 238 | $this->stop(false); 239 | $this->start(); 240 | } 241 | 242 | private function reload() { 243 | $pid = $this->getChildPid(); 244 | self::show("Sending SIGUSR1"); 245 | if ($pid && posix_kill($pid, SIGUSR1)) { 246 | self::ok(); 247 | } 248 | else { 249 | self::failed(); 250 | } 251 | exit; 252 | } 253 | 254 | private function status() { 255 | $pid = $this->getChildPid(); 256 | if (!$pid) { 257 | echo "Process is stopped\n"; 258 | exit(3); 259 | } 260 | if (posix_kill($pid, 0)) { 261 | echo "Process (pid $pid) is running...\n"; 262 | exit(0); 263 | } 264 | // # See if /var/lock/subsys/${base} exists 265 | // if [ -f /var/lock/subsys/${base} ]; then 266 | // echo $"${base} dead but subsys locked" 267 | // return 2 268 | // fi¬ 269 | else { 270 | echo "Process dead but pid file exists\n"; 271 | exit(1); 272 | } 273 | } 274 | 275 | private function kill() { 276 | $this->terminate('Sending SIGKILL', SIGKILL); 277 | exit; 278 | } 279 | 280 | private function getChildPid() { 281 | return file_exists($this->pidfile) 282 | ? file_get_contents($this->pidfile) 283 | : false; 284 | } 285 | 286 | // make output pretty 287 | private static $chars = 0; 288 | private static function show($text) { 289 | echo $text; 290 | self::$chars += strlen($text); 291 | } 292 | 293 | private static function ok() { 294 | echo str_repeat(' ', 59-self::$chars); 295 | echo "[\033[0;32m OK \033[0m]\n"; 296 | self::$chars = 0; 297 | } 298 | 299 | private static function failed() { 300 | echo str_repeat(' ', 59-self::$chars); 301 | echo "[\033[0;31mFAILED\033[0m]\n"; 302 | self::$chars = 0; 303 | } 304 | 305 | } 306 | -------------------------------------------------------------------------------- /src/GearmanWorkerManager.php: -------------------------------------------------------------------------------- 1 | [] 14 | private $config = array(); 15 | // [worker name => count] 16 | private $runCounts = []; 17 | // [worker name => nice]; 18 | private $niceties = []; 19 | // Functions this worker will expose to Gearman (set in child only) 20 | private $myCallbacks = []; 21 | // [ini function name => callable] 22 | private $registeredFunctions = []; 23 | // gearmand servers 24 | private $servers = '127.0.0.1:4730'; 25 | 26 | public function setServers(array $servers) { 27 | // Todo: validate? 28 | $this->servers = implode(',', $servers); 29 | return $this; 30 | } 31 | 32 | public function setConfigFile($path) { 33 | $config = parse_ini_file($path, $process_sections=true); 34 | $defaults = 35 | [ 'count' => 0 36 | , 'functions' => [] 37 | , 'runcount' => 0 38 | , 'nice' => 0 39 | ]; 40 | $types = []; 41 | foreach ($config as $section => $values) { 42 | $values += $defaults; 43 | if ($values['count'] < 1) { 44 | throw new \Exception("[$section] count must be a postive number"); 45 | } 46 | $types[$section] = (int) $values['count']; 47 | 48 | // Todo: automatic support for "all" and "unhandled" 49 | if (!$values['functions'] || !is_array($values['functions'])) { 50 | throw new \Exception("At least one function s required"); 51 | } 52 | $this->config[$section] = $values['functions']; 53 | $this->runCounts[$section] = (int) $values['runcount']; 54 | $this->niceties[$section] = (int) $values['nice']; 55 | } 56 | 57 | // Set up parent management config 58 | $this->setWorkerTypes($types); 59 | return $this; 60 | } 61 | 62 | protected function beforeWork() { 63 | foreach ($this->config[$this->workerType] as $name) { 64 | $this->myCallbacks[$name] = $this->registeredFunctions[$name]; 65 | } 66 | $this->setRunCount($this->runCounts[$this->workerType]); 67 | $this->setNice($this->niceties[$this->workerType]); 68 | } 69 | 70 | public function registerFunction($name, callable $fn) { 71 | $this->registeredFunctions[$name] = $fn; 72 | return $this; 73 | } 74 | 75 | /** @return true if work was attempted, false otherwise */ 76 | protected function doWork() { 77 | $worker = $this->getWorker(); 78 | if ($worker->work()) { 79 | $this->getLogger()->debug("$this->myPid processed a job"); 80 | $this->reconnects = 0; 81 | return true; 82 | } 83 | switch ($worker->returnCode()) { 84 | case GEARMAN_IO_WAIT: 85 | case GEARMAN_NO_JOBS: 86 | if (@$worker->wait()) { 87 | $this->getLogger()->debug("$this->myPid waited with no error"); 88 | $this->reconnects = 0; 89 | return false; 90 | } 91 | if ($worker->returnCode() == GEARMAN_NO_ACTIVE_FDS) { 92 | $this->getLogger()->error("$this->myPid Connection to gearmand server failed"); 93 | if (++$this->reconnects >= 5) { 94 | $this->getLogger()->error("$this->myPid Giving up"); 95 | $this->stopWorking(); 96 | } 97 | else { 98 | sleep(2); 99 | } 100 | } 101 | break; 102 | default: 103 | $this->getLogger()->error("$this->myPid exiting after getting code {$worker->returnCode()}"); 104 | $this->stopWorking(); 105 | } 106 | return false; // Assume no work was done 107 | } 108 | 109 | private function getWorker() { 110 | if (!$this->worker) { 111 | $this->getLogger()->debug("Building new worker"); 112 | $this->worker = new GearmanWorker(); 113 | $this->worker->addOptions(GEARMAN_WORKER_NON_BLOCKING); 114 | $this->worker->setTimeout(2500); 115 | try { 116 | $this->worker->addServers($this->servers); 117 | } catch (GearmanException $e) { 118 | // Swallow the error without logging 119 | // 120 | // PHP's gearman extension erroneously makes a network call in 121 | // here to set the exception option (I've looked through the 122 | // gearman server code to figure out what this is for, and have 123 | // no idea, but it's a bunch of messy C). If gearmand is 124 | // unavailable, addServers will throw, despite what the 125 | // documentation says. Regardless, a lack of gearmand server 126 | // availability is handled in doWork (GEARMAN_NO_ACTIVE_FDS) so 127 | // we want to silently ignore it in the startup process a) so 128 | // it matches the documented behavior and b) to prevent a fatal 129 | // error in the worker due to an uncaught exception. 130 | } 131 | foreach ($this->myCallbacks as $name => $cb) { 132 | $this->worker->addFunction($name, $cb); 133 | } 134 | } 135 | return $this->worker; 136 | } 137 | 138 | } 139 | -------------------------------------------------------------------------------- /src/ProcessManager.php: -------------------------------------------------------------------------------- 1 | type 11 | private $shouldWork = false; 12 | private $workers = 0; 13 | private $workerTypes = []; // name => count to spawn 14 | private $runCount = 0; // child: number of times to run before respawn 15 | private $nice = 0; // child: process nice level (see: man nice) 16 | private $roundsComplete = 0; // child: number of times work completed 17 | private $beforeWorkCallbacks = []; // parent: child type to array of CBs 18 | 19 | protected $myPid; 20 | protected $workerType; 21 | 22 | public function __construct(\Psr\Log\LoggerInterface $logger = null) { 23 | $this->managerPid = $this->myPid = getmypid(); 24 | if ($logger) { 25 | $this->setLogger($logger); 26 | } 27 | else { 28 | $this->setLogger(new \Psr\Log\NullLogger); 29 | } 30 | $this->installSignals(); 31 | } 32 | 33 | protected function getLogger() { 34 | return $this->logger; 35 | } 36 | 37 | public function addBeforeWorkCallback($workerType, callable $cb) { 38 | $this->beforeWorkCallbacks[$workerType][] = $cb; 39 | } // addBeforeWorkCallback 40 | 41 | public function setWorkerTypes(array $types) { 42 | $total = 0; 43 | foreach ($types as $name => $count) { 44 | if (!is_string($name)) { 45 | throw new \Exception("Worker type name must be a string"); 46 | } 47 | if (!is_int($count) || $count < 1) { 48 | throw new \Exception("Worker type count must be a positive integer"); 49 | } 50 | $this->beforeWorkCallbacks[$name] = []; // init the array 51 | $total += $count; 52 | } 53 | $this->workerTypes = $types; 54 | $this->workers = $total; 55 | return $this; 56 | } 57 | 58 | final public function start() { 59 | $this->shouldWork = true; 60 | $this->manageWorkers(); 61 | } 62 | 63 | /** @return bool did a child exit? */ 64 | private function cleanChildren() { 65 | $status = null; 66 | if ($exited = pcntl_wait($status, WNOHANG)) { 67 | unset($this->workerProcesses[$exited]); 68 | $this->getLogger()->info("Worker $exited got WNOHANG during normal operation"); 69 | return true; 70 | } 71 | return false; 72 | } 73 | 74 | /** @return true if work was done, false otherwise */ 75 | abstract protected function doWork(); 76 | 77 | private function installSignals() { 78 | $this->getLogger()->debug("Installing signals"); 79 | pcntl_signal(SIGTERM, [$this,'signal']); 80 | pcntl_signal(SIGINT, [$this,'signal']); 81 | pcntl_signal(SIGTRAP, [$this,'signal']); 82 | pcntl_signal(SIGHUP, [$this,'signal']); 83 | pcntl_signal(SIGCHLD, [$this,'signal']); 84 | } 85 | 86 | private function isParent() { 87 | return $this->myPid == $this->managerPid; 88 | } 89 | 90 | private function manageWorkers() { 91 | while ($this->shouldWork) { 92 | pcntl_signal_dispatch(); 93 | // Do nothing other than wait for SIGTERM/SIGINT 94 | if (count($this->workerProcesses) < $this->workers) { 95 | $currentWorkers = array_count_values($this->workerProcesses); 96 | foreach ($this->workerTypes as $type => $count) { 97 | if (!isset($currentWorkers[$type]) || $currentWorkers[$type] < $count) { 98 | $this->spawnWorker($type); 99 | } 100 | } 101 | } 102 | else { 103 | // Just in case a SIGCHLD was missed 104 | $this->cleanChildren(); 105 | } 106 | sleep(1); 107 | } 108 | $this->getLogger()->debug("Stopping work, waiting for children"); 109 | // For magical unixey reasons I don't understand, simply listening for 110 | // SIGHCLD isn't reliable enough here, so we have to spin on this and 111 | // manually watch for children to reap. Might just be a weird race 112 | // condition. Dunno. 113 | while ($this->workerProcesses) { 114 | if (!$this->cleanChildren()) { 115 | sleep(1); 116 | } 117 | } 118 | $this->getLogger()->debug("All children have stopped"); 119 | } 120 | 121 | public function signal($signo) { 122 | switch ($signo) { 123 | case SIGTERM: 124 | case SIGINT: 125 | $this->handleSigterm(); 126 | break; 127 | case SIGHUP: 128 | $this->handleSighup(); 129 | break; 130 | case SIGCHLD: 131 | $this->cleanChildren(); 132 | break; 133 | case SIGTRAP: 134 | $e = new \Exception; 135 | file_put_contents(sys_get_temp_dir().'/pm_backtrace_'.$this->myPid, 136 | $e->getTraceAsString()); 137 | break; 138 | default: 139 | $this->getLogger()->error("No signal handler for $signo"); 140 | break; 141 | } 142 | } 143 | 144 | private function handleSighup() { 145 | // Ignore SIGHUP unless a term request has already been received 146 | if ($this->shouldWork) { 147 | return; 148 | } 149 | if ($this->isParent()) { 150 | // Might move the handle second SIGTERM logic in here, but "SIGTERM 151 | // SIGTERM" seems like a more natural and slightly less error-prome 152 | // way to handle things, as the controlling terminal could SIGHUP 153 | // the parent unexpectedly. 154 | } 155 | else { // Child 156 | $this->getLogger()->info("Child received SIGHUP;". 157 | " detaching to finish the current job then exiting."); 158 | $newpid = pcntl_fork(); 159 | if (-1 === $newpid) { 160 | $this->getLogger()->error("Child detach-forking failed completely"); 161 | } 162 | elseif (0 === $newpid) { 163 | // Detached child, continue as normal 164 | } 165 | else { 166 | exit; // Original child attacked to parent 167 | } 168 | /* 169 | * Detaching from the terminal doesn't seem to do anything useful, 170 | * especially since this is normally going to be run as a daemon 171 | if (-1 === posix_setsid()) { 172 | $this->getLogger()->error("Child could not detach from parent". 173 | " to finish last piece of work"); 174 | } 175 | */ 176 | } 177 | } 178 | 179 | private function handleSigterm() { 180 | if ($this->isParent()) { 181 | $this->getLogger()->info('Parent got sigterm/sigint'); 182 | $this->getLogger()->debug("Children: ". 183 | print_r(array_keys($this->workerProcesses), true)); 184 | if (!$this->shouldWork) { 185 | $this->getLogger()->debug( 186 | "Parent got second SIGTERM, telling children to detach"); 187 | $this->stopChildren(SIGHUP); 188 | return; 189 | } 190 | $this->stopWorking(); 191 | $this->stopChildren(SIGTERM); 192 | } 193 | else { 194 | $this->getLogger()->info("Child $this->myPid received SIGTERM; stopping work"); 195 | $this->stopWorking(); 196 | } 197 | } 198 | 199 | private function spawnWorker($type) { 200 | $this->getLogger()->info("Creating a new worker of type $type"); 201 | switch ($pid = pcntl_fork()) { 202 | case -1: // Failed 203 | $this->getLogger()->error("Spawning worker failed"); 204 | exit(2); 205 | case 0: // Child 206 | $this->myPid = getmypid(); 207 | $this->workerType = $type; 208 | $this->getLogger()->info("$this->myPid created"); 209 | // Available since PHP 5.5 210 | if (function_exists('cli_set_process_title')) { 211 | cli_set_process_title($type); 212 | } 213 | $this->installSignals(); 214 | // Fixme: clean up and merge this before stuff 215 | foreach ($this->beforeWorkCallbacks[$type] as $cb) { 216 | $cb(); 217 | } 218 | $this->beforeWork(); 219 | $this->work(); 220 | break; 221 | default: // Parent 222 | $this->getLogger()->debug("Parent created child with pid $pid"); 223 | $this->workerProcesses[$pid] = $type; 224 | break; 225 | } 226 | } 227 | 228 | private function stopChildren($sig) { 229 | foreach ($this->workerProcesses as $pid => $type) { 230 | // I'd prefer logging the actual signal name here but there's not 231 | // a one-liner to convert AFAIK 232 | $this->getLogger()->debug("Sending signal $sig to $pid"); 233 | posix_kill($pid, $sig); 234 | if (!posix_kill($pid, 0)) { 235 | $this->getLogger()->debug("$pid is dead already"); 236 | } 237 | } 238 | } 239 | 240 | protected function stopWorking() { 241 | $this->shouldWork = false; 242 | } 243 | 244 | protected function beforeWork() { 245 | // hook, intentionally left empty 246 | } 247 | 248 | protected function setRunCount($count) { 249 | if (!is_int($count)) { 250 | throw new \Exception("Count must be an integer"); 251 | } 252 | elseif ($count < 0) { 253 | throw new \Exception("Count must be 0 or greater"); 254 | } 255 | $this->runCount = $count; 256 | } 257 | 258 | protected function setNice($level) { 259 | if (!is_int($level) || $level > 20 || $level < -20) { 260 | throw new \Exception("Nice must be an int between -20 and 20"); 261 | } 262 | $this->nice = $level; 263 | } 264 | 265 | private function work() { 266 | $this->getLogger()->debug("Child $this->myPid about to start work"); 267 | if ($this->nice) { 268 | $this->getLogger()->debug("Child being reniced to $this->nice"); 269 | proc_nice($this->nice); 270 | } 271 | while ($this->shouldWork) { 272 | pcntl_signal_dispatch(); 273 | $_SERVER['REQUEST_TIME'] = time(); 274 | $_SERVER['REQUEST_TIME_FLOAT'] = microtime(true); 275 | if ($this->doWork()) { 276 | $this->roundsComplete++; 277 | } 278 | // If runCount is 0, go indefinitely. Otherwise stop after runCount 279 | if ($this->runCount && $this->roundsComplete >= $this->runCount) { 280 | $this->stopWorking(); 281 | } 282 | } 283 | $this->getLogger()->info("Child has stopped working and will exit"); 284 | exit; 285 | } 286 | 287 | } 288 | --------------------------------------------------------------------------------