Added soft closes for connections on SIGTERM/SIGINT
This commit is contained in:
parent
86fbf76a0e
commit
ec47925c71
|
|
@ -56,6 +56,9 @@
|
||||||
"orchestra/database": "^4.0|^5.0|^6.0",
|
"orchestra/database": "^4.0|^5.0|^6.0",
|
||||||
"phpunit/phpunit": "^8.0|^9.0"
|
"phpunit/phpunit": "^8.0|^9.0"
|
||||||
},
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-pcntl": "Running the server needs pcntl to listen to command signals and soft-shutdown."
|
||||||
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"BeyondCode\\LaravelWebSockets\\": "src/"
|
"BeyondCode\\LaravelWebSockets\\": "src/"
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,13 @@ class LocalChannelManager implements ChannelManager
|
||||||
*/
|
*/
|
||||||
protected $users = [];
|
protected $users = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wether the current instance accepts new connections.
|
||||||
|
*
|
||||||
|
* @var bool
|
||||||
|
*/
|
||||||
|
protected $acceptsNewConnections = true;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new channel manager instance.
|
* Create a new channel manager instance.
|
||||||
*
|
*
|
||||||
|
|
@ -71,6 +78,28 @@ class LocalChannelManager implements ChannelManager
|
||||||
return $this->channels[$appId][$channel];
|
return $this->channels[$appId][$channel];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the local connections, regardless of the channel
|
||||||
|
* they are connected to.
|
||||||
|
*
|
||||||
|
* @return \React\Promise\PromiseInterface
|
||||||
|
*/
|
||||||
|
public function getLocalConnections(): PromiseInterface
|
||||||
|
{
|
||||||
|
$connections = collect($this->channels)
|
||||||
|
->map(function ($channelsWithConnections, $appId) {
|
||||||
|
return collect($channelsWithConnections)->values();
|
||||||
|
})
|
||||||
|
->values()->collapse()
|
||||||
|
->map(function ($channel) {
|
||||||
|
return collect($channel->getConnections());
|
||||||
|
})
|
||||||
|
->values()->collapse()
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
return new FulfilledPromise($connections);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all channels for a specific app
|
* Get all channels for a specific app
|
||||||
* for the current instance.
|
* for the current instance.
|
||||||
|
|
@ -313,6 +342,29 @@ class LocalChannelManager implements ChannelManager
|
||||||
return new FulfilledPromise($results);
|
return new FulfilledPromise($results);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark the current instance as unable to accept new connections.
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function declineNewConnections()
|
||||||
|
{
|
||||||
|
$this->acceptsNewConnections = false;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current server instance
|
||||||
|
* accepts new connections.
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function acceptsNewConnections(): bool
|
||||||
|
{
|
||||||
|
return $this->acceptsNewConnections;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the channel class by the channel name.
|
* Get the channel class by the channel name.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,17 @@ class RedisChannelManager extends LocalChannelManager
|
||||||
$this->serverId = Str::uuid()->toString();
|
$this->serverId = Str::uuid()->toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the local connections, regardless of the channel
|
||||||
|
* they are connected to.
|
||||||
|
*
|
||||||
|
* @return \React\Promise\PromiseInterface
|
||||||
|
*/
|
||||||
|
public function getLocalConnections(): PromiseInterface
|
||||||
|
{
|
||||||
|
return parent::getLocalConnections();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all channels for a specific app
|
* Get all channels for a specific app
|
||||||
* for the current instance.
|
* for the current instance.
|
||||||
|
|
@ -108,9 +119,9 @@ class RedisChannelManager extends LocalChannelManager
|
||||||
$connection, $channel, new stdClass
|
$connection, $channel, new stdClass
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
})->then(function () use ($connection) {
|
||||||
|
|
||||||
parent::unsubscribeFromAllChannels($connection);
|
parent::unsubscribeFromAllChannels($connection);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ class StartServer extends Command
|
||||||
{--disable-statistics : Disable the statistics tracking.}
|
{--disable-statistics : Disable the statistics tracking.}
|
||||||
{--statistics-interval= : The amount of seconds to tick between statistics saving.}
|
{--statistics-interval= : The amount of seconds to tick between statistics saving.}
|
||||||
{--debug : Forces the loggers to be enabled and thereby overriding the APP_DEBUG setting.}
|
{--debug : Forces the loggers to be enabled and thereby overriding the APP_DEBUG setting.}
|
||||||
{--test : Prepare the server, but do not start it.}
|
{--loop : Programatically inject the loop.}
|
||||||
';
|
';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -79,6 +79,8 @@ class StartServer extends Command
|
||||||
|
|
||||||
$this->configureRoutes();
|
$this->configureRoutes();
|
||||||
|
|
||||||
|
$this->configurePcntlSignal();
|
||||||
|
|
||||||
$this->startServer();
|
$this->startServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -156,6 +158,31 @@ class StartServer extends Command
|
||||||
WebSocketRouter::routes();
|
WebSocketRouter::routes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure the PCNTL signals for soft shutdown.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
protected function configurePcntlSignal()
|
||||||
|
{
|
||||||
|
// When the process receives a SIGTERM or a SIGINT
|
||||||
|
// signal, it should mark the server as unavailable
|
||||||
|
// to receive new connections, close the current connections,
|
||||||
|
// then stopping the loop.
|
||||||
|
|
||||||
|
$this->loop->addSignal(SIGTERM, function () {
|
||||||
|
$this->line('Closing existing connections...');
|
||||||
|
|
||||||
|
$this->triggerSoftShutdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->loop->addSignal(SIGINT, function () {
|
||||||
|
$this->line('Closing existing connections...');
|
||||||
|
|
||||||
|
$this->triggerSoftShutdown();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configure the HTTP logger class.
|
* Configure the HTTP logger class.
|
||||||
*
|
*
|
||||||
|
|
@ -209,14 +236,6 @@ class StartServer extends Command
|
||||||
|
|
||||||
$this->buildServer();
|
$this->buildServer();
|
||||||
|
|
||||||
// For testing, just boot up the server, run it
|
|
||||||
// but exit after the next tick.
|
|
||||||
if ($this->option('test')) {
|
|
||||||
$this->loop->futureTick(function () {
|
|
||||||
$this->loop->stop();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->server->run();
|
$this->server->run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -231,6 +250,10 @@ class StartServer extends Command
|
||||||
$this->option('host'), $this->option('port')
|
$this->option('host'), $this->option('port')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if ($loop = $this->option('loop')) {
|
||||||
|
$this->loop = $loop;
|
||||||
|
}
|
||||||
|
|
||||||
$this->server = $this->server
|
$this->server = $this->server
|
||||||
->setLoop($this->loop)
|
->setLoop($this->loop)
|
||||||
->withRoutes(WebSocketRouter::getRoutes())
|
->withRoutes(WebSocketRouter::getRoutes())
|
||||||
|
|
@ -249,4 +272,29 @@ class StartServer extends Command
|
||||||
'beyondcode:websockets:restart', 0
|
'beyondcode:websockets:restart', 0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger a soft shutdown for the process.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
protected function triggerSoftShutdown()
|
||||||
|
{
|
||||||
|
$channelManager = $this->laravel->make(ChannelManager::class);
|
||||||
|
|
||||||
|
// Close the new connections allowance on this server.
|
||||||
|
$channelManager->declineNewConnections();
|
||||||
|
|
||||||
|
// Get all local connections and close them. They will
|
||||||
|
// be automatically be unsubscribed from all channels.
|
||||||
|
$channelManager->getLocalConnections()
|
||||||
|
->then(function ($connections) use ($channelManager) {
|
||||||
|
foreach ($connections as $connection) {
|
||||||
|
$connection->close();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->then(function () {
|
||||||
|
$this->loop->stop();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,14 @@ interface ChannelManager
|
||||||
*/
|
*/
|
||||||
public function findOrCreate($appId, string $channel);
|
public function findOrCreate($appId, string $channel);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the local connections, regardless of the channel
|
||||||
|
* they are connected to.
|
||||||
|
*
|
||||||
|
* @return \React\Promise\PromiseInterface
|
||||||
|
*/
|
||||||
|
public function getLocalConnections(): PromiseInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all channels for a specific app
|
* Get all channels for a specific app
|
||||||
* for the current instance.
|
* for the current instance.
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,10 @@ class WebSocketHandler implements MessageComponentInterface
|
||||||
*/
|
*/
|
||||||
public function onOpen(ConnectionInterface $connection)
|
public function onOpen(ConnectionInterface $connection)
|
||||||
{
|
{
|
||||||
|
if (! $this->connectionCanBeMade($connection)) {
|
||||||
|
return $connection->close();
|
||||||
|
}
|
||||||
|
|
||||||
$this->verifyAppKey($connection)
|
$this->verifyAppKey($connection)
|
||||||
->verifyOrigin($connection)
|
->verifyOrigin($connection)
|
||||||
->limitConcurrentConnections($connection)
|
->limitConcurrentConnections($connection)
|
||||||
|
|
@ -69,6 +73,10 @@ class WebSocketHandler implements MessageComponentInterface
|
||||||
*/
|
*/
|
||||||
public function onMessage(ConnectionInterface $connection, MessageInterface $message)
|
public function onMessage(ConnectionInterface $connection, MessageInterface $message)
|
||||||
{
|
{
|
||||||
|
if (! isset($connection->app)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Messages\PusherMessageFactory::createForMessage(
|
Messages\PusherMessageFactory::createForMessage(
|
||||||
$message, $connection, $this->channelManager
|
$message, $connection, $this->channelManager
|
||||||
)->respond();
|
)->respond();
|
||||||
|
|
@ -113,6 +121,18 @@ class WebSocketHandler implements MessageComponentInterface
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the connection can be made for the
|
||||||
|
* current server instance.
|
||||||
|
*
|
||||||
|
* @param \Ratchet\ConnectionInterface $connection
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
protected function connectionCanBeMade(ConnectionInterface $connection): bool
|
||||||
|
{
|
||||||
|
return $this->channelManager->acceptsNewConnections();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify the app key validity.
|
* Verify the app key validity.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,43 @@ class StartServerTest extends TestCase
|
||||||
{
|
{
|
||||||
public function test_does_not_fail_if_building_up()
|
public function test_does_not_fail_if_building_up()
|
||||||
{
|
{
|
||||||
$this->artisan('websockets:serve', ['--test' => true, '--debug' => true]);
|
$this->loop->futureTick(function () {
|
||||||
|
$this->loop->stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->artisan('websockets:serve', ['--loop' => $this->loop, '--debug' => true, '--port' => 6001]);
|
||||||
|
|
||||||
|
$this->assertTrue(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_pcntl_sigint_signal()
|
||||||
|
{
|
||||||
|
$this->loop->futureTick(function () {
|
||||||
|
$this->newActiveConnection(['public-channel']);
|
||||||
|
$this->newActiveConnection(['public-channel']);
|
||||||
|
|
||||||
|
posix_kill(posix_getpid(), SIGINT);
|
||||||
|
|
||||||
|
$this->loop->stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->artisan('websockets:serve', ['--loop' => $this->loop, '--debug' => true, '--port' => 6002]);
|
||||||
|
|
||||||
|
$this->assertTrue(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_pcntl_sigterm_signal()
|
||||||
|
{
|
||||||
|
$this->loop->futureTick(function () {
|
||||||
|
$this->newActiveConnection(['public-channel']);
|
||||||
|
$this->newActiveConnection(['public-channel']);
|
||||||
|
|
||||||
|
posix_kill(posix_getpid(), SIGTERM);
|
||||||
|
|
||||||
|
$this->loop->stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->artisan('websockets:serve', ['--loop' => $this->loop, '--debug' => true, '--port' => 6003]);
|
||||||
|
|
||||||
$this->assertTrue(true);
|
$this->assertTrue(true);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -108,4 +108,22 @@ class ConnectionTest extends TestCase
|
||||||
->assertSentEvent('pusher:error', ['data' => ['message' => 'Over capacity', 'code' => 4100]])
|
->assertSentEvent('pusher:error', ['data' => ['message' => 'Over capacity', 'code' => 4100]])
|
||||||
->assertClosed();
|
->assertClosed();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_close_all_new_connections_after_stating_the_server_does_not_accept_new_connections()
|
||||||
|
{
|
||||||
|
$allowedConnection = $this->newActiveConnection(['test-channel']);
|
||||||
|
|
||||||
|
$allowedConnection->assertSentEvent('pusher:connection_established')
|
||||||
|
->assertSentEvent('pusher_internal:subscription_succeeded');
|
||||||
|
|
||||||
|
$this->channelManager->declineNewConnections();
|
||||||
|
|
||||||
|
$this->assertFalse(
|
||||||
|
$this->channelManager->acceptsNewConnections()
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->newActiveConnection(['test-channel'])
|
||||||
|
->assertNothingSent()
|
||||||
|
->assertClosed();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,18 @@ class Connection implements ConnectionInterface
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert that no events occured within the connection.
|
||||||
|
*
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function assertNothingSent()
|
||||||
|
{
|
||||||
|
PHPUnit::assertEquals([], $this->sentData);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assert the connection is closed.
|
* Assert the connection is closed.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace BeyondCode\LaravelWebSockets\Test;
|
namespace BeyondCode\LaravelWebSockets\Test;
|
||||||
|
|
||||||
use BeyondCode\LaravelWebSockets\Server\Exceptions\InvalidSignature;
|
use BeyondCode\LaravelWebSockets\Server\Exceptions\InvalidSignature;
|
||||||
|
use Ratchet\ConnectionInterface;
|
||||||
|
|
||||||
class PresenceChannelTest extends TestCase
|
class PresenceChannelTest extends TestCase
|
||||||
{
|
{
|
||||||
|
|
@ -185,4 +186,22 @@ class PresenceChannelTest extends TestCase
|
||||||
], $statistic->toArray());
|
], $statistic->toArray());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_local_connections_for_private_channels()
|
||||||
|
{
|
||||||
|
$this->newPresenceConnection('presence-channel', ['user_id' => 1]);
|
||||||
|
$this->newPresenceConnection('presence-channel-2', ['user_id' => 2]);
|
||||||
|
|
||||||
|
$this->channelManager
|
||||||
|
->getLocalConnections()
|
||||||
|
->then(function ($connections) {
|
||||||
|
$this->assertCount(2, $connections);
|
||||||
|
|
||||||
|
foreach ($connections as $connection) {
|
||||||
|
$this->assertInstanceOf(
|
||||||
|
ConnectionInterface::class, $connection
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace BeyondCode\LaravelWebSockets\Test;
|
namespace BeyondCode\LaravelWebSockets\Test;
|
||||||
|
|
||||||
use BeyondCode\LaravelWebSockets\Server\Exceptions\InvalidSignature;
|
use BeyondCode\LaravelWebSockets\Server\Exceptions\InvalidSignature;
|
||||||
|
use Ratchet\ConnectionInterface;
|
||||||
|
|
||||||
class PrivateChannelTest extends TestCase
|
class PrivateChannelTest extends TestCase
|
||||||
{
|
{
|
||||||
|
|
@ -138,4 +139,22 @@ class PrivateChannelTest extends TestCase
|
||||||
], $statistic->toArray());
|
], $statistic->toArray());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_local_connections_for_private_channels()
|
||||||
|
{
|
||||||
|
$this->newPrivateConnection('private-channel');
|
||||||
|
$this->newPrivateConnection('private-channel-2');
|
||||||
|
|
||||||
|
$this->channelManager
|
||||||
|
->getLocalConnections()
|
||||||
|
->then(function ($connections) {
|
||||||
|
$this->assertCount(2, $connections);
|
||||||
|
|
||||||
|
foreach ($connections as $connection) {
|
||||||
|
$this->assertInstanceOf(
|
||||||
|
ConnectionInterface::class, $connection
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
namespace BeyondCode\LaravelWebSockets\Test;
|
namespace BeyondCode\LaravelWebSockets\Test;
|
||||||
|
|
||||||
|
use Ratchet\ConnectionInterface;
|
||||||
|
|
||||||
class PublicChannelTest extends TestCase
|
class PublicChannelTest extends TestCase
|
||||||
{
|
{
|
||||||
public function test_connect_to_public_channel()
|
public function test_connect_to_public_channel()
|
||||||
|
|
@ -114,4 +116,22 @@ class PublicChannelTest extends TestCase
|
||||||
], $statistic->toArray());
|
], $statistic->toArray());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_local_connections_for_public_channels()
|
||||||
|
{
|
||||||
|
$this->newActiveConnection(['public-channel']);
|
||||||
|
$this->newActiveConnection(['public-channel-2']);
|
||||||
|
|
||||||
|
$this->channelManager
|
||||||
|
->getLocalConnections()
|
||||||
|
->then(function ($connections) {
|
||||||
|
$this->assertCount(2, $connections);
|
||||||
|
|
||||||
|
foreach ($connections as $connection) {
|
||||||
|
$this->assertInstanceOf(
|
||||||
|
ConnectionInterface::class, $connection
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue