Correct Aphlict websocket URI construction after PHP8 compatibility changes
[phabricator.git] / src / infrastructure / daemon / PhutilDaemonHandle.php
blob3792c3b773fa1eb13bcc598dbc5d4f295c7e046f
1 <?php
3 final class PhutilDaemonHandle extends Phobject {
5 const EVENT_DID_LAUNCH = 'daemon.didLaunch';
6 const EVENT_DID_LOG = 'daemon.didLogMessage';
7 const EVENT_DID_HEARTBEAT = 'daemon.didHeartbeat';
8 const EVENT_WILL_GRACEFUL = 'daemon.willGraceful';
9 const EVENT_WILL_EXIT = 'daemon.willExit';
11 private $pool;
12 private $properties;
13 private $future;
14 private $argv;
16 private $restartAt;
17 private $busyEpoch;
19 private $daemonID;
20 private $deadline;
21 private $heartbeat;
22 private $stdoutBuffer;
23 private $shouldRestart = true;
24 private $shouldShutdown;
25 private $hibernating = false;
26 private $shouldSendExitEvent = false;
28 private function __construct() {
29 // <empty>
32 public static function newFromConfig(array $config) {
33 PhutilTypeSpec::checkMap(
34 $config,
35 array(
36 'class' => 'string',
37 'argv' => 'optional list<string>',
38 'load' => 'optional list<string>',
39 'log' => 'optional string|null',
40 'down' => 'optional int',
41 ));
43 $config = $config + array(
44 'argv' => array(),
45 'load' => array(),
46 'log' => null,
47 'down' => 15,
50 $daemon = new self();
51 $daemon->properties = $config;
52 $daemon->daemonID = $daemon->generateDaemonID();
54 return $daemon;
57 public function setDaemonPool(PhutilDaemonPool $daemon_pool) {
58 $this->pool = $daemon_pool;
59 return $this;
62 public function getDaemonPool() {
63 return $this->pool;
66 public function getBusyEpoch() {
67 return $this->busyEpoch;
70 public function getDaemonClass() {
71 return $this->getProperty('class');
74 private function getProperty($key) {
75 return idx($this->properties, $key);
78 public function setCommandLineArguments(array $arguments) {
79 $this->argv = $arguments;
80 return $this;
83 public function getCommandLineArguments() {
84 return $this->argv;
87 public function getDaemonArguments() {
88 return $this->getProperty('argv');
91 public function didLaunch() {
92 $this->restartAt = time();
93 $this->shouldSendExitEvent = true;
95 $this->dispatchEvent(
96 self::EVENT_DID_LAUNCH,
97 array(
98 'argv' => $this->getCommandLineArguments(),
99 'explicitArgv' => $this->getDaemonArguments(),
102 return $this;
105 public function isRunning() {
106 return (bool)$this->getFuture();
109 public function isHibernating() {
110 return
111 !$this->isRunning() &&
112 !$this->isDone() &&
113 $this->hibernating;
116 public function wakeFromHibernation() {
117 if (!$this->isHibernating()) {
118 return $this;
121 $this->logMessage(
122 'WAKE',
123 pht(
124 'Process is being awakened from hibernation.'));
126 $this->restartAt = time();
127 $this->update();
129 return $this;
132 public function isDone() {
133 return (!$this->shouldRestart && !$this->isRunning());
136 public function update() {
137 if (!$this->isRunning()) {
138 if (!$this->shouldRestart) {
139 return;
141 if (!$this->restartAt || (time() < $this->restartAt)) {
142 return;
144 if ($this->shouldShutdown) {
145 return;
147 $this->startDaemonProcess();
150 $future = $this->getFuture();
152 $result = null;
153 $caught = null;
154 if ($future->canResolve()) {
155 $this->future = null;
156 try {
157 $result = $future->resolve();
158 } catch (Exception $ex) {
159 $caught = $ex;
160 } catch (Throwable $ex) {
161 $caught = $ex;
165 list($stdout, $stderr) = $future->read();
166 $future->discardBuffers();
168 if (strlen($stdout)) {
169 $this->didReadStdout($stdout);
172 $stderr = trim($stderr);
173 if (strlen($stderr)) {
174 foreach (phutil_split_lines($stderr, false) as $line) {
175 $this->logMessage('STDE', $line);
179 if ($result !== null || $caught !== null) {
181 if ($caught) {
182 $message = pht(
183 'Process failed with exception: %s',
184 $caught->getMessage());
185 $this->logMessage('FAIL', $message);
186 } else {
187 list($err) = $result;
189 if ($err) {
190 $this->logMessage('FAIL', pht('Process exited with error %s.', $err));
191 } else {
192 $this->logMessage('DONE', pht('Process exited normally.'));
196 if ($this->shouldShutdown) {
197 $this->restartAt = null;
198 } else {
199 $this->scheduleRestart();
203 $this->updateHeartbeatEvent();
204 $this->updateHangDetection();
207 private function updateHeartbeatEvent() {
208 if ($this->heartbeat > time()) {
209 return;
212 $this->heartbeat = time() + $this->getHeartbeatEventFrequency();
213 $this->dispatchEvent(self::EVENT_DID_HEARTBEAT);
216 private function updateHangDetection() {
217 if (!$this->isRunning()) {
218 return;
221 if (time() > $this->deadline) {
222 $this->logMessage('HANG', pht('Hang detected. Restarting process.'));
223 $this->annihilateProcessGroup();
224 $this->scheduleRestart();
228 private function scheduleRestart() {
229 // Wait a minimum of a few sceconds before restarting, but we may wait
230 // longer if the daemon has initiated hibernation.
231 $default_restart = time() + self::getWaitBeforeRestart();
232 if ($default_restart >= $this->restartAt) {
233 $this->restartAt = $default_restart;
236 $this->logMessage(
237 'WAIT',
238 pht(
239 'Waiting %s second(s) to restart process.',
240 new PhutilNumber($this->restartAt - time())));
244 * Generate a unique ID for this daemon.
246 * @return string A unique daemon ID.
248 private function generateDaemonID() {
249 return substr(getmypid().':'.Filesystem::readRandomCharacters(12), 0, 12);
252 public function getDaemonID() {
253 return $this->daemonID;
256 private function getFuture() {
257 return $this->future;
260 private function getPID() {
261 $future = $this->getFuture();
263 if (!$future) {
264 return null;
267 if (!$future->hasPID()) {
268 return null;
271 return $future->getPID();
274 private function getCaptureBufferSize() {
275 return 65535;
278 private function getRequiredHeartbeatFrequency() {
279 return 86400;
282 public static function getWaitBeforeRestart() {
283 return 5;
286 public static function getHeartbeatEventFrequency() {
287 return 120;
290 private function getKillDelay() {
291 return 3;
294 private function getDaemonCWD() {
295 $root = dirname(phutil_get_library_root('phabricator'));
296 return $root.'/scripts/daemon/exec/';
299 private function newExecFuture() {
300 $class = $this->getDaemonClass();
301 $argv = $this->getCommandLineArguments();
302 $buffer_size = $this->getCaptureBufferSize();
304 // NOTE: PHP implements proc_open() by running 'sh -c'. On most systems this
305 // is bash, but on Ubuntu it's dash. When you proc_open() using bash, you
306 // get one new process (the command you ran). When you proc_open() using
307 // dash, you get two new processes: the command you ran and a parent
308 // "dash -c" (or "sh -c") process. This means that the child process's PID
309 // is actually the 'dash' PID, not the command's PID. To avoid this, use
310 // 'exec' to replace the shell process with the real process; without this,
311 // the child will call posix_getppid(), be given the pid of the 'sh -c'
312 // process, and send it SIGUSR1 to keepalive which will terminate it
313 // immediately. We also won't be able to do process group management because
314 // the shell process won't properly posix_setsid() so the pgid of the child
315 // won't be meaningful.
317 $config = $this->properties;
318 unset($config['class']);
319 $config = phutil_json_encode($config);
321 return id(new ExecFuture('exec ./exec_daemon.php %s %Ls', $class, $argv))
322 ->setCWD($this->getDaemonCWD())
323 ->setStdoutSizeLimit($buffer_size)
324 ->setStderrSizeLimit($buffer_size)
325 ->write($config);
329 * Dispatch an event to event listeners.
331 * @param string Event type.
332 * @param dict Event parameters.
333 * @return void
335 private function dispatchEvent($type, array $params = array()) {
336 $data = array(
337 'id' => $this->getDaemonID(),
338 'daemonClass' => $this->getDaemonClass(),
339 'childPID' => $this->getPID(),
340 ) + $params;
342 $event = new PhutilEvent($type, $data);
344 try {
345 PhutilEventEngine::dispatchEvent($event);
346 } catch (Exception $ex) {
347 phlog($ex);
351 private function annihilateProcessGroup() {
352 $pid = $this->getPID();
353 if ($pid) {
354 $pgid = posix_getpgid($pid);
355 if ($pgid) {
356 posix_kill(-$pgid, SIGTERM);
357 sleep($this->getKillDelay());
358 posix_kill(-$pgid, SIGKILL);
363 private function startDaemonProcess() {
364 $this->logMessage('INIT', pht('Starting process.'));
366 $this->deadline = time() + $this->getRequiredHeartbeatFrequency();
367 $this->heartbeat = time() + self::getHeartbeatEventFrequency();
368 $this->stdoutBuffer = '';
369 $this->hibernating = false;
371 $future = $this->newExecFuture();
372 $this->future = $future;
374 $pool = $this->getDaemonPool();
375 $overseer = $pool->getOverseer();
376 $overseer->addFutureToPool($future);
379 private function didReadStdout($data) {
380 $this->stdoutBuffer .= $data;
381 while (true) {
382 $pos = strpos($this->stdoutBuffer, "\n");
383 if ($pos === false) {
384 break;
386 $message = substr($this->stdoutBuffer, 0, $pos);
387 $this->stdoutBuffer = substr($this->stdoutBuffer, $pos + 1);
389 try {
390 $structure = phutil_json_decode($message);
391 } catch (PhutilJSONParserException $ex) {
392 $structure = array();
395 switch (idx($structure, 0)) {
396 case PhutilDaemon::MESSAGETYPE_STDOUT:
397 $this->logMessage('STDO', idx($structure, 1));
398 break;
399 case PhutilDaemon::MESSAGETYPE_HEARTBEAT:
400 $this->deadline = time() + $this->getRequiredHeartbeatFrequency();
401 break;
402 case PhutilDaemon::MESSAGETYPE_BUSY:
403 if (!$this->busyEpoch) {
404 $this->busyEpoch = time();
406 break;
407 case PhutilDaemon::MESSAGETYPE_IDLE:
408 $this->busyEpoch = null;
409 break;
410 case PhutilDaemon::MESSAGETYPE_DOWN:
411 // The daemon is exiting because it doesn't have enough work and it
412 // is trying to scale the pool down. We should not restart it.
413 $this->shouldRestart = false;
414 $this->shouldShutdown = true;
415 break;
416 case PhutilDaemon::MESSAGETYPE_HIBERNATE:
417 $config = idx($structure, 1);
418 $duration = (int)idx($config, 'duration', 0);
419 $this->restartAt = time() + $duration;
420 $this->hibernating = true;
421 $this->busyEpoch = null;
422 $this->logMessage(
423 'ZZZZ',
424 pht(
425 'Process is preparing to hibernate for %s second(s).',
426 new PhutilNumber($duration)));
427 break;
428 default:
429 // If we can't parse this or it isn't a message we understand, just
430 // emit the raw message.
431 $this->logMessage('STDO', pht('<Malformed> %s', $message));
432 break;
437 public function didReceiveNotifySignal($signo) {
438 $pid = $this->getPID();
439 if ($pid) {
440 posix_kill($pid, $signo);
444 public function didReceiveReloadSignal($signo) {
445 $signame = phutil_get_signal_name($signo);
446 if ($signame) {
447 $sigmsg = pht(
448 'Reloading in response to signal %d (%s).',
449 $signo,
450 $signame);
451 } else {
452 $sigmsg = pht(
453 'Reloading in response to signal %d.',
454 $signo);
457 $this->logMessage('RELO', $sigmsg, $signo);
459 // This signal means "stop the current process gracefully, then launch
460 // a new identical process once it exits". This can be used to update
461 // daemons after code changes (the new processes will run the new code)
462 // without aborting any running tasks.
464 // We SIGINT the daemon but don't set the shutdown flag, so it will
465 // naturally be restarted after it exits, as though it had exited after an
466 // unhandled exception.
468 $pid = $this->getPID();
469 if ($pid) {
470 posix_kill($pid, SIGINT);
474 public function didReceiveGracefulSignal($signo) {
475 $this->shouldShutdown = true;
476 $this->shouldRestart = false;
478 $signame = phutil_get_signal_name($signo);
479 if ($signame) {
480 $sigmsg = pht(
481 'Graceful shutdown in response to signal %d (%s).',
482 $signo,
483 $signame);
484 } else {
485 $sigmsg = pht(
486 'Graceful shutdown in response to signal %d.',
487 $signo);
490 $this->logMessage('DONE', $sigmsg, $signo);
492 $pid = $this->getPID();
493 if ($pid) {
494 posix_kill($pid, SIGINT);
498 public function didReceiveTerminateSignal($signo) {
499 $this->shouldShutdown = true;
500 $this->shouldRestart = false;
502 $signame = phutil_get_signal_name($signo);
503 if ($signame) {
504 $sigmsg = pht(
505 'Shutting down in response to signal %s (%s).',
506 $signo,
507 $signame);
508 } else {
509 $sigmsg = pht('Shutting down in response to signal %s.', $signo);
512 $this->logMessage('EXIT', $sigmsg, $signo);
513 $this->annihilateProcessGroup();
516 private function logMessage($type, $message, $context = null) {
517 $this->getDaemonPool()->logMessage($type, $message, $context);
519 $this->dispatchEvent(
520 self::EVENT_DID_LOG,
521 array(
522 'type' => $type,
523 'message' => $message,
524 'context' => $context,
528 public function didExit() {
529 if ($this->shouldSendExitEvent) {
530 $this->dispatchEvent(self::EVENT_WILL_EXIT);
531 $this->shouldSendExitEvent = false;
534 return $this;