205 lines
7.7 KiB
PHP
205 lines
7.7 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace BlaxSoftware\LaravelWebSockets\Test\Websocket;
|
||
|
|
|
||
|
|
use BlaxSoftware\LaravelWebSockets\Ipc\SocketPairIpc;
|
||
|
|
use BlaxSoftware\LaravelWebSockets\Server\ChildProcesses;
|
||
|
|
use BlaxSoftware\LaravelWebSockets\Test\Mocks;
|
||
|
|
use BlaxSoftware\LaravelWebSockets\Test\TestCase;
|
||
|
|
use BlaxSoftware\LaravelWebSockets\Websocket\ChildMessageOutcome;
|
||
|
|
use BlaxSoftware\LaravelWebSockets\Websocket\ControllerResolver;
|
||
|
|
use BlaxSoftware\LaravelWebSockets\Websocket\EventRegistry;
|
||
|
|
use BlaxSoftware\LaravelWebSockets\Websocket\Handler;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* End to end through Handler::forkWithSocketPair(), with real message
|
||
|
|
* children (#2465, #1462):
|
||
|
|
* - the child closes its copy of the server's listening socket,
|
||
|
|
* - the shutdown stops a child that is still running,
|
||
|
|
* - a child that dies without replying is answered by the parent,
|
||
|
|
* - a child that replied gets no extra frame.
|
||
|
|
*/
|
||
|
|
class HandlerChildLifecycleTest extends TestCase
|
||
|
|
{
|
||
|
|
private const FIXTURES_NS = 'BlaxSoftware\\LaravelWebSockets\\Test\\Websocket\\Fixtures\\ChildCrash';
|
||
|
|
|
||
|
|
public function setUp(): void
|
||
|
|
{
|
||
|
|
parent::setUp();
|
||
|
|
|
||
|
|
if (! SocketPairIpc::isSupported() || ! function_exists('pcntl_fork') || ! function_exists('posix_kill')) {
|
||
|
|
$this->markTestSkipped('pcntl, posix and sockets are required');
|
||
|
|
}
|
||
|
|
|
||
|
|
if (! class_exists(\Illuminate\Support\Defer\DeferredCallbackCollection::class)) {
|
||
|
|
require_once __DIR__ . '/Fixtures/DeferredCallbackCollectionStub.php';
|
||
|
|
}
|
||
|
|
|
||
|
|
ChildProcesses::flush();
|
||
|
|
ControllerResolver::clearCache();
|
||
|
|
EventRegistry::clear();
|
||
|
|
EventRegistry::setSearchPaths([
|
||
|
|
__DIR__ . '/Fixtures/ChildCrash' => self::FIXTURES_NS,
|
||
|
|
]);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function tearDown(): void
|
||
|
|
{
|
||
|
|
// Never leave a forked child behind, even after a failed assertion.
|
||
|
|
ChildProcesses::terminateAll(0.0, 1.0);
|
||
|
|
ChildProcesses::flush();
|
||
|
|
|
||
|
|
EventRegistry::clear();
|
||
|
|
(new \ReflectionProperty(EventRegistry::class, 'searchPaths'))->setValue(null, null);
|
||
|
|
|
||
|
|
parent::tearDown();
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_a_child_killed_before_replying_is_answered_by_the_parent()
|
||
|
|
{
|
||
|
|
$connection = $this->send('child-crash.killed');
|
||
|
|
|
||
|
|
$this->runLoopUntil(fn () => $this->sentEvent($connection, 'child-crash.killed:error') !== null);
|
||
|
|
|
||
|
|
$error = $this->sentEvent($connection, 'child-crash.killed:error');
|
||
|
|
$this->assertNotNull($error, 'The client must not wait forever for a child that died.');
|
||
|
|
$this->assertSame('Internal server error', $error['data']['message']);
|
||
|
|
$this->assertFalse($error['data']['meta']['reported']);
|
||
|
|
$this->assertSame(0, ChildProcesses::count(), 'The dead child was reaped and forgotten.');
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_a_child_that_replied_gets_no_extra_error_frame()
|
||
|
|
{
|
||
|
|
$connection = $this->send('child-crash.ok');
|
||
|
|
|
||
|
|
$this->runLoopUntil(
|
||
|
|
fn () => ChildProcesses::count() === 0 && $this->sentEvent($connection, 'child-crash.ok:response') !== null,
|
||
|
|
5.0,
|
||
|
|
0.2
|
||
|
|
);
|
||
|
|
|
||
|
|
$response = $this->sentEvent($connection, 'child-crash.ok:response');
|
||
|
|
$this->assertNotNull($response);
|
||
|
|
$this->assertSame(['ok' => true], $response['data']);
|
||
|
|
$connection->assertNotSentEvent('child-crash.ok:error');
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_a_running_child_neither_holds_the_port_nor_survives_the_shutdown()
|
||
|
|
{
|
||
|
|
$server = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr);
|
||
|
|
$this->assertIsResource($server, $errstr);
|
||
|
|
$port = (int) parse_url('tcp://' . stream_socket_get_name($server, false), PHP_URL_PORT);
|
||
|
|
$this->assertTrue(ChildProcesses::registerListener($server));
|
||
|
|
|
||
|
|
$connection = $this->send('child-crash.hold');
|
||
|
|
$this->assertSame(1, ChildProcesses::count());
|
||
|
|
[$pid] = ChildProcesses::pids();
|
||
|
|
|
||
|
|
// The server closes its socket while the child is still busy: the port
|
||
|
|
// frees as soon as the child has dropped its inherited copy.
|
||
|
|
fclose($server);
|
||
|
|
$successor = false;
|
||
|
|
$deadline = microtime(true) + 5;
|
||
|
|
while ($successor === false && microtime(true) < $deadline) {
|
||
|
|
$successor = @stream_socket_server("tcp://127.0.0.1:{$port}", $errno, $errstr);
|
||
|
|
if ($successor === false) {
|
||
|
|
usleep(20_000);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
$this->assertIsResource($successor, "The busy child kept port {$port} bound: {$errstr}");
|
||
|
|
fclose($successor);
|
||
|
|
$this->assertTrue(posix_kill($pid, 0), 'The child is still running its handler.');
|
||
|
|
|
||
|
|
// Server shutdown: SIGTERM does end the child (default disposition
|
||
|
|
// restored after fork), no SIGKILL needed.
|
||
|
|
$stats = ChildProcesses::terminateAll(2.0);
|
||
|
|
$this->assertSame(['terminated' => 1, 'killed' => 0, 'remaining' => 0], $stats);
|
||
|
|
$this->assertFalse(@posix_kill($pid, 0));
|
||
|
|
|
||
|
|
// Its client is told instead of waiting for a reply that never comes.
|
||
|
|
$this->runLoopUntil(fn () => $this->sentEvent($connection, 'child-crash.hold:error') !== null);
|
||
|
|
$error = $this->sentEvent($connection, 'child-crash.hold:error');
|
||
|
|
$this->assertNotNull($error);
|
||
|
|
$this->assertSame('Server is restarting', $error['data']['message']);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function test_the_fatal_error_frame_is_recognised_as_the_reply()
|
||
|
|
{
|
||
|
|
$method = new \ReflectionMethod(Handler::class, 'fatalErrorFrame');
|
||
|
|
if (PHP_VERSION_ID < 80100) {
|
||
|
|
$method->setAccessible(true);
|
||
|
|
}
|
||
|
|
|
||
|
|
$message = ['event' => 'child-crash.ok[u1]', 'data' => [], 'channel' => 'crash-channel'];
|
||
|
|
$frame = $method->invoke(null, $message, 'crash-channel');
|
||
|
|
|
||
|
|
// Same shape as the controller's catch-all error frame.
|
||
|
|
$this->assertSame([
|
||
|
|
'event' => 'child-crash.ok[u1]:error',
|
||
|
|
'data' => ['message' => 'Internal server error', 'meta' => ['reported' => true, 'fatal' => true]],
|
||
|
|
'channel' => 'crash-channel',
|
||
|
|
], json_decode($frame, true));
|
||
|
|
|
||
|
|
// The parent must count it as the reply, or it would add its own
|
||
|
|
// error frame on top of it.
|
||
|
|
$outcome = new ChildMessageOutcome($message['event'], fn () => null);
|
||
|
|
$outcome->frameReceived($frame);
|
||
|
|
|
||
|
|
$this->assertTrue($outcome->replied());
|
||
|
|
}
|
||
|
|
|
||
|
|
private function send(string $event): Mocks\Connection
|
||
|
|
{
|
||
|
|
$connection = $this->newActiveConnection(['crash-channel']);
|
||
|
|
$connection->resetEvents();
|
||
|
|
|
||
|
|
$this->wsHandler->onMessage($connection, new Mocks\Message([
|
||
|
|
'event' => $event,
|
||
|
|
'data' => [],
|
||
|
|
'channel' => 'crash-channel',
|
||
|
|
]));
|
||
|
|
|
||
|
|
return $connection;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Run the loop with StartServer's reaper until $done or the timeout, then
|
||
|
|
* keep it running for $settle seconds.
|
||
|
|
*/
|
||
|
|
private function runLoopUntil(callable $done, float $timeout = 5.0, float $settle = 0.0): void
|
||
|
|
{
|
||
|
|
$finishedAt = null;
|
||
|
|
|
||
|
|
$tick = $this->loop->addPeriodicTimer(0.02, function () use ($done, $settle, &$finishedAt) {
|
||
|
|
ChildProcesses::reapExited();
|
||
|
|
|
||
|
|
if ($finishedAt === null && $done()) {
|
||
|
|
$finishedAt = microtime(true);
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($finishedAt !== null && microtime(true) - $finishedAt >= $settle) {
|
||
|
|
$this->loop->stop();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
$timeoutTimer = $this->loop->addTimer($timeout, fn () => $this->loop->stop());
|
||
|
|
|
||
|
|
$this->loop->run();
|
||
|
|
|
||
|
|
$this->loop->cancelTimer($tick);
|
||
|
|
$this->loop->cancelTimer($timeoutTimer);
|
||
|
|
}
|
||
|
|
|
||
|
|
private function sentEvent(Mocks\Connection $connection, string $event): ?array
|
||
|
|
{
|
||
|
|
foreach ($connection->sentData as $frame) {
|
||
|
|
if (($frame['event'] ?? null) === $event) {
|
||
|
|
return $frame;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|