Split cron daemon into 2 classes

[MAILPOET-1538]
This commit is contained in:
Pavel Dohnal
2018-10-04 08:27:31 +02:00
parent f81323ad52
commit b2e2087cfc
5 changed files with 333 additions and 276 deletions

68
lib/Cron/Daemon.php Normal file
View File

@@ -0,0 +1,68 @@
<?php
namespace MailPoet\Cron;
use MailPoet\Cron\Workers\Scheduler as SchedulerWorker;
use MailPoet\Cron\Workers\SendingQueue\Migration as MigrationWorker;
use MailPoet\Cron\Workers\SendingQueue\SendingErrorHandler;
use MailPoet\Cron\Workers\SendingQueue\SendingQueue as SendingQueueWorker;
use MailPoet\Cron\Workers\Bounce as BounceWorker;
use MailPoet\Cron\Workers\KeyCheck\PremiumKeyCheck as PremiumKeyCheckWorker;
use MailPoet\Cron\Workers\KeyCheck\SendingServiceKeyCheck as SendingServiceKeyCheckWorker;
if(!defined('ABSPATH')) exit;
require_once(ABSPATH . 'wp-includes/pluggable.php');
class Daemon {
public $timer;
function __construct() {
$this->timer = microtime(true);
}
function run($settings_daemon_data) {
$settings_daemon_data['run_started_at'] = time();
CronHelper::saveDaemon($settings_daemon_data);
try {
$this->executeMigrationWorker();
$this->executeScheduleWorker();
$this->executeQueueWorker();
$this->executeSendingServiceKeyCheckWorker();
$this->executePremiumKeyCheckWorker();
$this->executeBounceWorker();
} catch(\Exception $e) {
CronHelper::saveDaemonLastError($e->getMessage());
}
// Log successful execution
CronHelper::saveDaemonRunCompleted(time());
}
function executeScheduleWorker() {
$scheduler = new SchedulerWorker($this->timer);
return $scheduler->process();
}
function executeQueueWorker() {
$queue = new SendingQueueWorker(new SendingErrorHandler(), $this->timer);
return $queue->process();
}
function executeSendingServiceKeyCheckWorker() {
$worker = new SendingServiceKeyCheckWorker($this->timer);
return $worker->process();
}
function executePremiumKeyCheckWorker() {
$worker = new PremiumKeyCheckWorker($this->timer);
return $worker->process();
}
function executeBounceWorker() {
$bounce = new BounceWorker($this->timer);
return $bounce->process();
}
function executeMigrationWorker() {
$migration = new MigrationWorker($this->timer);
return $migration->process();
}
}

View File

@@ -1,28 +1,29 @@
<?php <?php
namespace MailPoet\Cron; namespace MailPoet\Cron;
use MailPoet\Cron\Workers\Scheduler as SchedulerWorker;
use MailPoet\Cron\Workers\SendingQueue\Migration as MigrationWorker;
use MailPoet\Cron\Workers\SendingQueue\SendingErrorHandler;
use MailPoet\Cron\Workers\SendingQueue\SendingQueue as SendingQueueWorker;
use MailPoet\Cron\Workers\Bounce as BounceWorker;
use MailPoet\Cron\Workers\KeyCheck\PremiumKeyCheck as PremiumKeyCheckWorker;
use MailPoet\Cron\Workers\KeyCheck\SendingServiceKeyCheck as SendingServiceKeyCheckWorker;
if(!defined('ABSPATH')) exit; if(!defined('ABSPATH')) exit;
require_once(ABSPATH . 'wp-includes/pluggable.php'); require_once(ABSPATH . 'wp-includes/pluggable.php');
class DaemonHttpRunner { class DaemonHttpRunner {
public $daemon; public $settings_daemon_data;
public $request_data; public $request_data;
public $timer; public $timer;
public $token;
/** @var Daemon */
private $daemon;
const PING_SUCCESS_RESPONSE = 'pong'; const PING_SUCCESS_RESPONSE = 'pong';
function __construct($request_data = false) { function __construct($request_data = false, $daemon = null) {
$this->request_data = $request_data; $this->request_data = $request_data;
$this->daemon = CronHelper::getDaemon(); $this->settings_daemon_data = CronHelper::getDaemon();
$this->token = CronHelper::createToken(); $this->token = CronHelper::createToken();
$this->timer = microtime(true); $this->timer = microtime(true);
$this->daemon = $daemon;
if(!$daemon) {
$this->daemon = new Daemon($this->settings_daemon_data);
}
} }
function ping() { function ping() {
@@ -39,11 +40,11 @@ class DaemonHttpRunner {
if(!$this->request_data) { if(!$this->request_data) {
$error = __('Invalid or missing request data.', 'mailpoet'); $error = __('Invalid or missing request data.', 'mailpoet');
} else { } else {
if(!$this->daemon) { if(!$this->settings_daemon_data) {
$error = __('Daemon does not exist.', 'mailpoet'); $error = __('Daemon does not exist.', 'mailpoet');
} else { } else {
if(!isset($this->request_data['token']) || if(!isset($this->request_data['token']) ||
$this->request_data['token'] !== $this->daemon['token'] $this->request_data['token'] !== $this->settings_daemon_data['token']
) { ) {
$error = 'Invalid or missing token.'; $error = 'Invalid or missing token.';
} }
@@ -52,22 +53,8 @@ class DaemonHttpRunner {
if(!empty($error)) { if(!empty($error)) {
return $this->abortWithError($error); return $this->abortWithError($error);
} }
$daemon = $this->daemon; $this->settings_daemon_data['token'] = $this->token;
$daemon['token'] = $this->token; $this->daemon->run($this->settings_daemon_data);
$daemon['run_started_at'] = time();
CronHelper::saveDaemon($daemon);
try {
$this->executeMigrationWorker();
$this->executeScheduleWorker();
$this->executeQueueWorker();
$this->executeSendingServiceKeyCheckWorker();
$this->executePremiumKeyCheckWorker();
$this->executeBounceWorker();
} catch(\Exception $e) {
CronHelper::saveDaemonLastError($e->getMessage());
}
// Log successful execution
CronHelper::saveDaemonRunCompleted(time());
// if workers took less time to execute than the daemon execution limit, // if workers took less time to execute than the daemon execution limit,
// pause daemon execution to ensure that daemon runs only once every X seconds // pause daemon execution to ensure that daemon runs only once every X seconds
$elapsed_time = microtime(true) - $this->timer; $elapsed_time = microtime(true) - $this->timer;
@@ -75,8 +62,8 @@ class DaemonHttpRunner {
$this->pauseExecution(CronHelper::DAEMON_EXECUTION_LIMIT - $elapsed_time); $this->pauseExecution(CronHelper::DAEMON_EXECUTION_LIMIT - $elapsed_time);
} }
// after each execution, re-read daemon data in case it changed // after each execution, re-read daemon data in case it changed
$daemon = CronHelper::getDaemon(); $settings_daemon_data = CronHelper::getDaemon();
if($this->shouldTerminateExecution($daemon)) { if($this->shouldTerminateExecution($settings_daemon_data)) {
return $this->terminateRequest(); return $this->terminateRequest();
} }
return $this->callSelf(); return $this->callSelf();
@@ -86,39 +73,9 @@ class DaemonHttpRunner {
return sleep($pause_time); return sleep($pause_time);
} }
function executeScheduleWorker() {
$scheduler = new SchedulerWorker($this->timer);
return $scheduler->process();
}
function executeQueueWorker() {
$queue = new SendingQueueWorker(new SendingErrorHandler(), $this->timer);
return $queue->process();
}
function executeSendingServiceKeyCheckWorker() {
$worker = new SendingServiceKeyCheckWorker($this->timer);
return $worker->process();
}
function executePremiumKeyCheckWorker() {
$worker = new PremiumKeyCheckWorker($this->timer);
return $worker->process();
}
function executeBounceWorker() {
$bounce = new BounceWorker($this->timer);
return $bounce->process();
}
function executeMigrationWorker() {
$migration = new MigrationWorker($this->timer);
return $migration->process();
}
function callSelf() { function callSelf() {
CronHelper::accessDaemon($this->token); CronHelper::accessDaemon($this->token);
return $this->terminateRequest(); $this->terminateRequest();
} }
function abortWithError($message) { function abortWithError($message) {
@@ -131,12 +88,14 @@ class DaemonHttpRunner {
} }
/** /**
* @param array|null $settings_daemon_data
*
* @return boolean * @return boolean
*/ */
private function shouldTerminateExecution(array $daemon = null) { private function shouldTerminateExecution(array $settings_daemon_data = null) {
return !$daemon || return !$settings_daemon_data ||
$daemon['token'] !== $this->token || $settings_daemon_data['token'] !== $this->token ||
(isset($daemon['status']) && $daemon['status'] !== CronHelper::DAEMON_STATUS_ACTIVE); (isset($settings_daemon_data['status']) && $settings_daemon_data['status'] !== CronHelper::DAEMON_STATUS_ACTIVE);
} }
private function addCacheHeaders() { private function addCacheHeaders() {

View File

@@ -30,5 +30,10 @@ if(version_compare(phpversion(), '5.5.0', '<')) {
exit(1); exit(1);
} }
$trigger = new \MailPoet\Cron\Triggers\MailPoet(); if(strpos(@ini_get('disable_functions'), 'set_time_limit') === false) {
$trigger->run(); set_time_limit(0);
}
$data = \MailPoet\Cron\CronHelper::createDaemon(null);
$trigger = new \MailPoet\Cron\Daemon();
$trigger->run($data);

View File

@@ -0,0 +1,227 @@
<?php
namespace MailPoet\Test\Cron;
use Codeception\Stub;
use Codeception\Stub\Expected;
use MailPoet\Cron\CronHelper;
use MailPoet\Cron\Daemon;
use MailPoet\Cron\DaemonHttpRunner;
use MailPoet\Models\Setting;
class DaemonHttpRunnerTest extends \MailPoetTest {
function testItConstructs() {
Setting::setValue(
CronHelper::DAEMON_SETTING,
[]
);
$daemon = new DaemonHttpRunner($request_data = 'request data');
expect($daemon->request_data)->equals('request data');
expect(strlen($daemon->timer))->greaterOrEquals(5);
expect(strlen($daemon->token))->greaterOrEquals(5);
}
function testItDoesNotRunWithoutRequestData() {
$daemon = Stub::construct(
new DaemonHttpRunner(),
array(),
array(
'abortWithError' => function($message) {
return $message;
}
)
);
$daemon->request_data = false;
expect($daemon->run())->equals('Invalid or missing request data.');
}
function testItDoesNotRunWhenThereIsInvalidOrMissingToken() {
$daemon = Stub::construct(
new DaemonHttpRunner(),
array(),
array(
'abortWithError' => function($message) {
return $message;
}
)
);
$daemon->settings_daemon_data = array(
'token' => 123
);
$daemon->request_data = array('token' => 456);
expect($daemon->run())->equals('Invalid or missing token.');
}
function testItStoresErrorMessageAndContinuesExecutionWhenWorkersThrowException() {
$data = array(
'token' => 123
);
$daemon = Stub::make(new Daemon(), array(
'executeScheduleWorker' => function() {
throw new \Exception('Message');
},
'executeQueueWorker' => function() {
throw new \Exception();
},
), $this);
$daemon_http_runner = Stub::make(new DaemonHttpRunner($data, $daemon), array(
'pauseExecution' => null,
'callSelf' => null
), $this);
Setting::setValue(CronHelper::DAEMON_SETTING, $data);
$daemon_http_runner->__construct($data, $daemon);
$daemon_http_runner->run();
$updated_daemon = Setting::getValue(CronHelper::DAEMON_SETTING);
expect($updated_daemon['last_error'])->greaterOrEquals('Message');
}
function testItCanPauseExecution() {
$daemon = Stub::make(new Daemon(), array(
'executeScheduleWorker' => null,
'executeQueueWorker' => null,
), $this);
$daemon_http_runner = Stub::make(new DaemonHttpRunner(true, $daemon), array(
'pauseExecution' => Expected::exactly(1, function($pause_delay) {
expect($pause_delay)->lessThan(CronHelper::DAEMON_EXECUTION_LIMIT);
expect($pause_delay)->greaterThan(CronHelper::DAEMON_EXECUTION_LIMIT - 1);
}),
'callSelf' => null
), $this);
$data = array(
'token' => 123
);
Setting::setValue(CronHelper::DAEMON_SETTING, $data);
$daemon_http_runner->__construct($data, $daemon);
$daemon_http_runner->run();
}
function testItTerminatesExecutionWhenDaemonIsDeleted() {
$daemon = Stub::make(new DaemonHttpRunner(true), array(
'executeScheduleWorker' => function() {
Setting::deleteValue(CronHelper::DAEMON_SETTING);
},
'executeQueueWorker' => null,
'pauseExecution' => null,
'terminateRequest' => Expected::exactly(1)
), $this);
$data = array(
'token' => 123
);
Setting::setValue(CronHelper::DAEMON_SETTING, $data);
$daemon->__construct($data);
$daemon->run();
}
function testItTerminatesExecutionWhenDaemonTokenChangesAndKeepsChangedToken() {
$daemon = Stub::make(new DaemonHttpRunner(true), array(
'executeScheduleWorker' => function() {
Setting::setValue(
CronHelper::DAEMON_SETTING,
array('token' => 567)
);
},
'executeQueueWorker' => null,
'pauseExecution' => null,
'terminateRequest' => Expected::exactly(1)
), $this);
$data = array(
'token' => 123
);
Setting::setValue(CronHelper::DAEMON_SETTING, $data);
$daemon->__construct($data);
$daemon->run();
$data_after_run = Setting::getValue(CronHelper::DAEMON_SETTING);
expect($data_after_run['token'], 567);
}
function testItTerminatesExecutionWhenDaemonIsDeactivated() {
$daemon = Stub::make(new DaemonHttpRunner(true), [
'executeScheduleWorker' => null,
'executeQueueWorker' => null,
'pauseExecution' => null,
'terminateRequest' => Expected::exactly(1)
], $this);
$data = [
'token' => 123,
'status' => CronHelper::DAEMON_STATUS_INACTIVE,
];
Setting::setValue(CronHelper::DAEMON_SETTING, $data);
$daemon->__construct($data);
$daemon->run();
}
function testItUpdatesDaemonTokenDuringExecution() {
$daemon_http_runner = Stub::make(new DaemonHttpRunner(true), array(
'executeScheduleWorker' => null,
'executeQueueWorker' => null,
'pauseExecution' => null,
'callSelf' => null
), $this);
$data = array(
'token' => 123
);
Setting::setValue(CronHelper::DAEMON_SETTING, $data);
$daemon_http_runner->__construct($data);
$daemon_http_runner->run();
$updated_daemon = Setting::getValue(CronHelper::DAEMON_SETTING);
expect($updated_daemon['token'])->equals($daemon_http_runner->token);
}
function testItUpdatesTimestampsDuringExecution() {
$daemon = Stub::make(new Daemon(), array(
'executeScheduleWorker' => function() {
sleep(2);
},
'executeQueueWorker' => null,
), $this);
$daemon_http_runner = Stub::make(new DaemonHttpRunner(true, $daemon), array(
'pauseExecution' => null,
'callSelf' => null
), $this);
$data = array(
'token' => 123,
);
$now = time();
Setting::setValue(CronHelper::DAEMON_SETTING, $data);
$daemon_http_runner->__construct($data, $daemon);
$daemon_http_runner->run();
$updated_daemon = Setting::getValue(CronHelper::DAEMON_SETTING);
expect($updated_daemon['run_started_at'])->greaterOrEquals($now);
expect($updated_daemon['run_started_at'])->lessThan($now + 2);
expect($updated_daemon['run_completed_at'])->greaterOrEquals($now + 2);
expect($updated_daemon['run_completed_at'])->lessThan($now + 4);
}
function testItCanRun() {
ignore_user_abort(0);
expect(ignore_user_abort())->equals(0);
$daemon = Stub::make(new DaemonHttpRunner(true), array(
'pauseExecution' => null,
// workers should be executed
'executeScheduleWorker' => Expected::exactly(1),
'executeQueueWorker' => Expected::exactly(1),
// daemon should call itself
'callSelf' => Expected::exactly(1),
), $this);
$data = array(
'token' => 123
);
Setting::setValue(CronHelper::DAEMON_SETTING, $data);
$daemon->__construct($data);
$daemon->run();
expect(ignore_user_abort())->equals(1);
}
function testItRespondsToPingRequest() {
$daemon = Stub::make(new DaemonHttpRunner(true), array(
'terminateRequest' => Expected::exactly(1, function($message) {
expect($message)->equals('pong');
})
), $this);
$daemon->ping();
}
function _after() {
\ORM::raw_execute('TRUNCATE ' . Setting::$_table);
}
}

View File

@@ -5,68 +5,13 @@ use Codeception\Stub;
use Codeception\Stub\Expected; use Codeception\Stub\Expected;
use MailPoet\Cron\CronHelper; use MailPoet\Cron\CronHelper;
use MailPoet\Cron\DaemonHttpRunner; use MailPoet\Cron\DaemonHttpRunner;
use MailPoet\Cron\Daemon;
use MailPoet\Models\Setting; use MailPoet\Models\Setting;
class DaemonHttpRunnerTest extends \MailPoetTest { class DaemonTest extends \MailPoetTest {
function testItConstructs() {
Setting::setValue(
CronHelper::DAEMON_SETTING,
'daemon object'
);
$daemon = new DaemonHttpRunner($request_data = 'request data');
expect($daemon->daemon)->equals('daemon object');
expect($daemon->request_data)->equals('request data');
expect(strlen($daemon->timer))->greaterOrEquals(5);
expect(strlen($daemon->token))->greaterOrEquals(5);
}
function testItDoesNotRunWithoutRequestData() {
$daemon = Stub::construct(
new DaemonHttpRunner(),
array(),
array(
'abortWithError' => function($message) {
return $message;
}
)
);
$daemon->request_data = false;
expect($daemon->run())->equals('Invalid or missing request data.');
}
function testItDoesNotRunWhenDaemonIsNotFound() {
$daemon = Stub::construct(
new DaemonHttpRunner(),
array(),
array(
'abortWithError' => function($message) {
return $message;
}
)
);
$daemon->request_data = true;
expect($daemon->run())->equals('Daemon does not exist.');
}
function testItDoesNotRunWhenThereIsInvalidOrMissingToken() {
$daemon = Stub::construct(
new DaemonHttpRunner(),
array(),
array(
'abortWithError' => function($message) {
return $message;
}
)
);
$daemon->daemon = array(
'token' => 123
);
$daemon->request_data = array('token' => 456);
expect($daemon->run())->equals('Invalid or missing token.');
}
function testItCanExecuteWorkers() { function testItCanExecuteWorkers() {
$daemon = Stub::make(new DaemonHttpRunner(true), array( $daemon = Stub::make(new Daemon(), array(
'executeScheduleWorker' => Expected::exactly(1), 'executeScheduleWorker' => Expected::exactly(1),
'executeQueueWorker' => Expected::exactly(1), 'executeQueueWorker' => Expected::exactly(1),
'pauseExecution' => null, 'pauseExecution' => null,
@@ -77,148 +22,11 @@ class DaemonHttpRunnerTest extends \MailPoetTest {
); );
Setting::setValue(CronHelper::DAEMON_SETTING, $data); Setting::setValue(CronHelper::DAEMON_SETTING, $data);
$daemon->__construct($data); $daemon->__construct($data);
$daemon->run(); $daemon->run([]);
}
function testItStoresErrorMessageAndContinuesExecutionWhenWorkersThrowException() {
$daemon = Stub::make(new DaemonHttpRunner(true), array(
'executeScheduleWorker' => function() {
throw new \Exception('Message');
},
'executeQueueWorker' => function() {
throw new \Exception();
},
'pauseExecution' => null,
'callSelf' => null
), $this);
$data = array(
'token' => 123
);
Setting::setValue(CronHelper::DAEMON_SETTING, $data);
$daemon->__construct($data);
$daemon->run();
$updated_daemon = Setting::getValue(CronHelper::DAEMON_SETTING);
expect($updated_daemon['last_error'])->greaterOrEquals('Message');
}
function testItCanPauseExecution() {
$daemon = Stub::make(new DaemonHttpRunner(true), array(
'executeScheduleWorker' => null,
'executeQueueWorker' => null,
'pauseExecution' => Expected::exactly(1, function($pause_delay) {
expect($pause_delay)->lessThan(CronHelper::DAEMON_EXECUTION_LIMIT);
expect($pause_delay)->greaterThan(CronHelper::DAEMON_EXECUTION_LIMIT - 1);
}),
'callSelf' => null
), $this);
$data = array(
'token' => 123
);
Setting::setValue(CronHelper::DAEMON_SETTING, $data);
$daemon->__construct($data);
$daemon->run();
}
function testItTerminatesExecutionWhenDaemonIsDeleted() {
$daemon = Stub::make(new DaemonHttpRunner(true), array(
'executeScheduleWorker' => function() {
Setting::deleteValue(CronHelper::DAEMON_SETTING);
},
'executeQueueWorker' => null,
'pauseExecution' => null,
'terminateRequest' => Expected::exactly(1)
), $this);
$data = array(
'token' => 123
);
Setting::setValue(CronHelper::DAEMON_SETTING, $data);
$daemon->__construct($data);
$daemon->run();
}
function testItTerminatesExecutionWhenDaemonTokenChangesAndKeepsChangedToken() {
$daemon = Stub::make(new DaemonHttpRunner(true), array(
'executeScheduleWorker' => function() {
Setting::setValue(
CronHelper::DAEMON_SETTING,
array('token' => 567)
);
},
'executeQueueWorker' => null,
'pauseExecution' => null,
'terminateRequest' => Expected::exactly(1)
), $this);
$data = array(
'token' => 123
);
Setting::setValue(CronHelper::DAEMON_SETTING, $data);
$daemon->__construct($data);
$daemon->run();
$data_after_run = Setting::getValue(CronHelper::DAEMON_SETTING);
expect($data_after_run['token'], 567);
}
function testItTerminatesExecutionWhenDaemonIsDeactivated() {
$daemon = Stub::make(new DaemonHttpRunner(true), [
'executeScheduleWorker' => null,
'executeQueueWorker' => null,
'pauseExecution' => null,
'terminateRequest' => Expected::exactly(1)
], $this);
$data = [
'token' => 123,
'status' => CronHelper::DAEMON_STATUS_INACTIVE,
];
Setting::setValue(CronHelper::DAEMON_SETTING, $data);
$daemon->__construct($data);
$daemon->run();
}
function testItUpdatesDaemonTokenDuringExecution() {
$daemon = Stub::make(new DaemonHttpRunner(true), array(
'executeScheduleWorker' => null,
'executeQueueWorker' => null,
'pauseExecution' => null,
'callSelf' => null
), $this);
$data = array(
'token' => 123
);
Setting::setValue(CronHelper::DAEMON_SETTING, $data);
$daemon->__construct($data);
$daemon->run();
$updated_daemon = Setting::getValue(CronHelper::DAEMON_SETTING);
expect($updated_daemon['token'])->equals($daemon->token);
}
function testItUpdatesTimestampsDuringExecution() {
$daemon = Stub::make(new DaemonHttpRunner(true), array(
'executeScheduleWorker' => function() {
sleep(2);
},
'executeQueueWorker' => null,
'pauseExecution' => null,
'callSelf' => null
), $this);
$data = array(
'token' => 123,
);
$now = time();
Setting::setValue(CronHelper::DAEMON_SETTING, $data);
$daemon->__construct($data);
$daemon->run();
$updated_daemon = Setting::getValue(CronHelper::DAEMON_SETTING);
expect($updated_daemon['run_started_at'])->greaterOrEquals($now);
expect($updated_daemon['run_started_at'])->lessThan($now + 2);
expect($updated_daemon['run_completed_at'])->greaterOrEquals($now + 2);
expect($updated_daemon['run_completed_at'])->lessThan($now + 4);
} }
function testItCanRun() { function testItCanRun() {
ignore_user_abort(0); $daemon = Stub::make(new Daemon(), array(
expect(ignore_user_abort())->equals(0);
$daemon = Stub::make(new DaemonHttpRunner(true), array(
'pauseExecution' => null, 'pauseExecution' => null,
// workers should be executed // workers should be executed
'executeScheduleWorker' => Expected::exactly(1), 'executeScheduleWorker' => Expected::exactly(1),
@@ -230,18 +38,8 @@ class DaemonHttpRunnerTest extends \MailPoetTest {
'token' => 123 'token' => 123
); );
Setting::setValue(CronHelper::DAEMON_SETTING, $data); Setting::setValue(CronHelper::DAEMON_SETTING, $data);
$daemon->__construct($data); $daemon->__construct();
$daemon->run(); $daemon->run($data);
expect(ignore_user_abort())->equals(1);
}
function testItRespondsToPingRequest() {
$daemon = Stub::make(new DaemonHttpRunner(true), array(
'terminateRequest' => Expected::exactly(1, function($message) {
expect($message)->equals('pong');
})
), $this);
$daemon->ping();
} }
function _after() { function _after() {