From d0064f0b85a5d7fa12e87a6854a113bba8209048 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sun, 25 Oct 2026 15:04:32 +0100 Subject: [PATCH] Update test suite to ensure 100% code coverage --- .github/workflows/ci.yml | 9 +++++- README.md | 10 +++++++ src/Factory.php | 16 ++++------- tests/FunctionalTest.php | 60 +++++++++++++++++++++++++++++++++++++++- 4 files changed, 82 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 982bcc8..3dfbb5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,14 @@ jobs: php-version: ${{ matrix.php }} coverage: xdebug - run: composer install - - run: vendor/bin/phpunit --coverage-text ${{ matrix.php < 7.3 && '-c phpunit.xml.legacy' || '' }} + - run: vendor/bin/phpunit --coverage-text --coverage-clover clover.xml ${{ matrix.php < 7.3 && '-c phpunit.xml.legacy' || '' }} + - name: Check 100% code coverage + if: ${{ matrix.php >= 5.4 }} + shell: php {0} + run: | + project->metrics; + exit((int) $metrics['statements'] === (int) $metrics['coveredstatements'] ? 0 : 1); PHPUnit-hhvm: name: PHPUnit (HHVM) diff --git a/README.md b/README.md index 6cd2c5b..b087981 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # clue/reactphp-multicast [![CI status](https://github.com/clue/reactphp-multicast/actions/workflows/ci.yml/badge.svg)](https://github.com/clue/reactphp-multicast/actions) +[![code coverage](https://img.shields.io/badge/code%20coverage-100%25-success)](#tests) [![installs on Packagist](https://img.shields.io/packagist/dt/clue/multicast-react?color=blue&label=installs%20on%20Packagist)](https://packagist.org/packages/clue/multicast-react) Simple, event-driven multicast UDP message client and server for [ReactPHP](https://reactphp.org/). @@ -186,6 +187,15 @@ To run the test suite, go to the project root and run: vendor/bin/phpunit ``` +The test suite is set up to always ensure 100% code coverage across all +supported environments (except `createReceiver()` on legacy PHP 5.3). If you +have the Xdebug extension installed, you can also generate a code coverage +report locally like this: + +```bash +XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text +``` + ## License This project is released under the permissive [MIT license](LICENSE). diff --git a/src/Factory.php b/src/Factory.php index a861e6b..27b044a 100644 --- a/src/Factory.php +++ b/src/Factory.php @@ -58,6 +58,8 @@ public function __construct($loop = null) public function createSender() { $stream = @stream_socket_server('udp://0.0.0.0:0', $errno, $errstr, STREAM_SERVER_BIND); + + // unlikely: stream_socket_server() may return false if no more file descriptors are available if ($stream === false) { throw new RuntimeException('Unable to create sending socket: ' . $errstr, $errno); } @@ -104,10 +106,10 @@ public function createSender() public function createReceiver($address) { if (!defined('MCAST_JOIN_GROUP')) { - throw new BadMethodCallException('MCAST_JOIN_GROUP not defined (requires PHP 5.4+)'); + throw new BadMethodCallException('MCAST_JOIN_GROUP not defined (requires PHP 5.4+)'); // @codeCoverageIgnore } if (!function_exists('socket_import_stream')) { - throw new BadMethodCallException('Function socket_import_stream missing (requires ext-sockets and PHP 5.4+)'); + throw new BadMethodCallException('Function socket_import_stream missing (requires ext-sockets and PHP 5.4+)'); // @codeCoverageIgnore } $parts = parse_url('udp://' . $address); @@ -118,15 +120,7 @@ public function createReceiver($address) } $socket = socket_import_stream($stream); - if ($stream === false) { - throw new RuntimeException('Unable to access underlying socket resource'); - } - - // allow multiple processes to bind to the same address - $ret = socket_set_option($socket, SOL_SOCKET, SO_REUSEADDR, 1); - if ($ret === false) { - throw new RuntimeException('Unable to enable SO_REUSEADDR'); - } + assert($socket !== false); set_error_handler(function () { // ignore PHP warning, error is reported via socket_last_error() below diff --git a/tests/FunctionalTest.php b/tests/FunctionalTest.php index 59e446c..b516d76 100644 --- a/tests/FunctionalTest.php +++ b/tests/FunctionalTest.php @@ -58,12 +58,70 @@ public function testMultipleReceivers() $this->loop->run(); } + public function testCreateSenderThrowsWhenFileDescriptorsAreExceeded() + { + $ulimit = exec('ulimit -n 2>&1', $_, $exit); + if ($exit !== 0 || $ulimit < 1) { + $this->markTestSkipped('Unable to determine limit of open files (ulimit not available?)'); + } + + $memory = ini_get('memory_limit'); + if ($memory === '-1') { + $memory = PHP_INT_MAX; + } elseif (preg_match('/^\d+G$/i', $memory)) { + $memory = ((int) $memory) * 1024 * 1024 * 1024; + } elseif (preg_match('/^\d+M$/i', $memory)) { + $memory = ((int) $memory) * 1024 * 1024; + } elseif (preg_match('/^\d+K$/i', $memory)) { + $memory = ((int) $memory) * 1024; + } + + // each file descriptor takes ~600 bytes of memory, so skip test if this would exceed memory_limit + if ($ulimit * 600 > $memory || $ulimit > 100000) { + $this->markTestSkipped('Test requires ~' . round($ulimit * 600 / 1024 / 1024) . '/' . round($memory / 1024 / 1024) . ' MiB memory with ' . $ulimit . ' file descriptors'); + } + + // keep creating dummy file handles until all file descriptors are exhausted + $fds = array(); + for ($i = 0; $i < $ulimit; ++$i) { + $fd = @fopen('/dev/null', 'r'); + if ($fd === false) { + break; + } + $fds[] = $fd; + } + + $this->setExpectedException('RuntimeException', 'Unable to create sending socket: '); + $this->factory->createSender(); + } + + public function testCreateReceiverThrowsForPortAlreadyInUse() + { + if (!defined('MCAST_JOIN_GROUP') || !function_exists('socket_import_stream')) { + $this->markTestSkipped('No multicast support'); + } + + // bind to random port without SO_REUSEADDR, so receiver can not bind to same port + $socket = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP); + socket_bind($socket, '0.0.0.0', 0); + socket_getsockname($socket, $host, $port); + + $this->setExpectedException('RuntimeException', 'Unable to create receiving socket: ' . socket_strerror(SOCKET_EADDRINUSE)); + $this->factory->createReceiver('224.224.244.244:' . $port); + } + public function testCreateReceiverThrowsForNonMulticastAddressWithoutCallingCustomErrorHandler() { if (!defined('MCAST_JOIN_GROUP') || !function_exists('socket_import_stream')) { $this->markTestSkipped('No multicast support'); } + // find random free port, as createReceiver() can not parse port 0 on legacy PHP + $socket = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP); + socket_bind($socket, '0.0.0.0', 0); + socket_getsockname($socket, $host, $port); + socket_close($socket); + $error = null; set_error_handler(function ($_, $errstr) use (&$error) { $error = $errstr; @@ -72,7 +130,7 @@ public function testCreateReceiverThrowsForNonMulticastAddressWithoutCallingCust $this->setExpectedException('RuntimeException', 'Unable to join multicast group: ' . socket_strerror(SOCKET_EINVAL), SOCKET_EINVAL); try { - $this->factory->createReceiver('127.0.0.1:2244'); + $this->factory->createReceiver('127.0.0.1:' . $port); restore_error_handler(); } catch (\Exception $e) {