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; } }