From fd9a1ab5d8e639648fd9dc01040cdf5b088cbad1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 24 Aug 2018 19:27:49 +0200 Subject: [PATCH 1/4] Internal `FdServer` implementation to listen on file descriptors (FDs) --- src/FdServer.php | 187 +++++++++++++++++++++++ tests/FdServerTest.php | 338 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 525 insertions(+) create mode 100644 src/FdServer.php create mode 100644 tests/FdServerTest.php diff --git a/src/FdServer.php b/src/FdServer.php new file mode 100644 index 00000000..d17ae1f1 --- /dev/null +++ b/src/FdServer.php @@ -0,0 +1,187 @@ +on('connection', function (ConnectionInterface $connection) { + * echo 'Plaintext connection from ' . $connection->getRemoteAddress() . PHP_EOL; + * $connection->write('hello there!' . PHP_EOL); + * … + * }); + * ``` + * + * See also the `ServerInterface` for more details. + * + * @see ServerInterface + * @see ConnectionInterface + * @internal + */ +final class FdServer extends EventEmitter implements ServerInterface +{ + private $master; + private $loop; + private $listening = false; + + /** + * Creates a socket server and starts listening on the given file descriptor + * + * This starts accepting new incoming connections on the given file descriptor. + * See also the `connection event` documented in the `ServerInterface` + * for more details. + * + * ```php + * $socket = new React\Socket\FdServer(3); + * ``` + * + * If the given FD is invalid or out of range, it will throw an `InvalidArgumentException`: + * + * ```php + * // throws InvalidArgumentException + * $socket = new React\Socket\FdServer(-1); + * ``` + * + * If the given FD appears to be valid, but listening on it fails (such as + * if the FD does not exist or does not refer to a socket server), it will + * throw a `RuntimeException`: + * + * ```php + * // throws RuntimeException because FD does not reference a socket server + * $socket = new React\Socket\FdServer(0, $loop); + * ``` + * + * Note that these error conditions may vary depending on your system and/or + * configuration. + * See the exception message and code for more details about the actual error + * condition. + * + * @param int $fd + * @param ?LoopInterface $loop + * @throws \InvalidArgumentException if the listening address is invalid + * @throws \RuntimeException if listening on this address fails (already in use etc.) + */ + public function __construct($fd, LoopInterface $loop = null) + { + if (!\is_int($fd) || $fd < 0 || $fd >= \PHP_INT_MAX) { + throw new \InvalidArgumentException('Invalid FD number given'); + } + + $this->loop = $loop ?: Loop::get(); + + $this->master = @\fopen('php://fd/' . $fd, 'r+'); + if (false === $this->master) { + // Match errstr from PHP's warning message. + // fopen(php://fd/3): Failed to open stream: Error duping file descriptor 3; possibly it doesn't exist: [9]: Bad file descriptor + $error = \error_get_last(); + \preg_match('/\[(\d+)\]: (.*)/', $error['message'], $m); + $errno = isset($m[1]) ? (int) $m[1] : 0; + $errstr = isset($m[2]) ? $m[2] : $error['message']; + + throw new \RuntimeException('Failed to listen on FD ' . $fd . ': ' . $errstr, $errno); + } + + $meta = \stream_get_meta_data($this->master); + if (!isset($meta['stream_type']) || $meta['stream_type'] !== 'tcp_socket') { + \fclose($this->master); + + $errno = \defined('SOCKET_ENOTSOCK') ? \SOCKET_ENOTSOCK : 88; + $errstr = \function_exists('socket_strerror') ? \socket_strerror($errno) : 'Not a socket'; + + throw new \RuntimeException('Failed to listen on FD ' . $fd . ': ' . $errstr, $errno); + } + + // Socket should not have a peer address if this is a listening socket. + // Looks like this work-around is the closest we can get because PHP doesn't expose SO_ACCEPTCONN even with ext-sockets. + if (\stream_socket_get_name($this->master, true) !== false) { + \fclose($this->master); + + $errno = \defined('SOCKET_EISCONN') ? \SOCKET_EISCONN : 106; + $errstr = \function_exists('socket_strerror') ? \socket_strerror($errno) : 'Socket is connected'; + + throw new \RuntimeException('Failed to listen on FD ' . $fd . ': ' . $errstr, $errno); + } + + \stream_set_blocking($this->master, false); + + $this->resume(); + } + + public function getAddress() + { + if (!\is_resource($this->master)) { + return null; + } + + $address = \stream_socket_get_name($this->master, false); + + // check if this is an IPv6 address which includes multiple colons but no square brackets + $pos = \strrpos($address, ':'); + if ($pos !== false && \strpos($address, ':') < $pos && \substr($address, 0, 1) !== '[') { + $address = '[' . \substr($address, 0, $pos) . ']:' . \substr($address, $pos + 1); // @codeCoverageIgnore + } + + return 'tcp://' . $address; + } + + public function pause() + { + if (!$this->listening) { + return; + } + + $this->loop->removeReadStream($this->master); + $this->listening = false; + } + + public function resume() + { + if ($this->listening || !\is_resource($this->master)) { + return; + } + + $that = $this; + $this->loop->addReadStream($this->master, function ($master) use ($that) { + try { + $newSocket = SocketServer::accept($master); + } catch (\RuntimeException $e) { + $that->emit('error', array($e)); + return; + } + $that->handleConnection($newSocket); + }); + $this->listening = true; + } + + public function close() + { + if (!\is_resource($this->master)) { + return; + } + + $this->pause(); + \fclose($this->master); + $this->removeAllListeners(); + } + + /** @internal */ + public function handleConnection($socket) + { + $this->emit('connection', array( + new Connection($socket, $this->loop) + )); + } +} diff --git a/tests/FdServerTest.php b/tests/FdServerTest.php new file mode 100644 index 00000000..c8c9098c --- /dev/null +++ b/tests/FdServerTest.php @@ -0,0 +1,338 @@ +markTestSkipped('Not supported on your platform'); + } + + $socket = stream_socket_server('127.0.0.1:0'); + $fd = $this->getFdFromResource($socket); + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addReadStream'); + + new FdServer($fd, $loop); + } + + public function testCtorThrowsForInvalidFd() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->never())->method('addReadStream'); + + $this->setExpectedException('InvalidArgumentException'); + new FdServer(-1, $loop); + } + + public function testCtorThrowsForUnknownFd() + { + if (!is_dir('/dev/fd') || defined('HHVM_VERSION')) { + $this->markTestSkipped('Not supported on your platform'); + } + + $socket = stream_socket_server('127.0.0.1:0'); + $fd = $this->getFdFromResource($socket); + fclose($socket); + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->never())->method('addReadStream'); + + $this->setExpectedException( + 'RuntimeException', + 'Failed to listen on FD ' . $fd . ': ' . (function_exists('socket_strerror') ? socket_strerror(SOCKET_EBADF) : 'Bad file descriptor'), + defined('SOCKET_EBADF') ? SOCKET_EBADF : 9 + ); + new FdServer($fd, $loop); + } + + public function testCtorThrowsIfFdIsAFileAndNotASocket() + { + if (!is_dir('/dev/fd') || defined('HHVM_VERSION')) { + $this->markTestSkipped('Not supported on your platform'); + } + + $tmpfile = tmpfile(); + $fd = $this->getFdFromResource($tmpfile); + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->never())->method('addReadStream'); + + $this->setExpectedException( + 'RuntimeException', + 'Failed to listen on FD ' . $fd . ': ' . (function_exists('socket_strerror') ? socket_strerror(SOCKET_ENOTSOCK) : 'Not a socket'), + defined('SOCKET_ENOTSOCK') ? SOCKET_ENOTSOCK : 88 + ); + new FdServer($fd, $loop); + } + + public function testCtorThrowsIfFdIsAConnectedSocketInsteadOfServerSocket() + { + if (!is_dir('/dev/fd') || defined('HHVM_VERSION')) { + $this->markTestSkipped('Not supported on your platform'); + } + + $socket = stream_socket_server('tcp://127.0.0.1:0'); + $client = stream_socket_client('tcp://' . stream_socket_get_name($socket, false)); + + $fd = $this->getFdFromResource($client); + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->never())->method('addReadStream'); + + $this->setExpectedException( + 'RuntimeException', + 'Failed to listen on FD ' . $fd . ': ' . (function_exists('socket_strerror') ? socket_strerror(SOCKET_EISCONN) : 'Socket is connected'), + defined('SOCKET_EISCONN') ? SOCKET_EISCONN : 106 + ); + new FdServer($fd, $loop); + } + + public function testGetAddressReturnsSameAddressAsOriginalSocketForIpv4Socket() + { + if (!is_dir('/dev/fd') || defined('HHVM_VERSION')) { + $this->markTestSkipped('Not supported on your platform'); + } + + $socket = stream_socket_server('127.0.0.1:0'); + $fd = $this->getFdFromResource($socket); + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $server = new FdServer($fd, $loop); + + $this->assertEquals('tcp://' . stream_socket_get_name($socket, false), $server->getAddress()); + } + + public function testGetAddressReturnsSameAddressAsOriginalSocketForIpv6Socket() + { + if (!is_dir('/dev/fd') || defined('HHVM_VERSION')) { + $this->markTestSkipped('Not supported on your platform'); + } + + $socket = @stream_socket_server('[::1]:0'); + if ($socket === false) { + $this->markTestSkipped('IPv6 not supported '); + } + + $fd = $this->getFdFromResource($socket); + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $server = new FdServer($fd, $loop); + + $port = preg_replace('/.*:/', '', stream_socket_get_name($socket, false)); + $this->assertEquals('tcp://[::1]:' . $port, $server->getAddress()); + } + + public function testGetAddressReturnsNullAfterClose() + { + if (!is_dir('/dev/fd') || defined('HHVM_VERSION')) { + $this->markTestSkipped('Not supported on your platform'); + } + + $socket = stream_socket_server('127.0.0.1:0'); + $fd = $this->getFdFromResource($socket); + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $server = new FdServer($fd, $loop); + $server->close(); + + $this->assertNull($server->getAddress()); + } + + public function testCloseRemovesResourceFromLoop() + { + if (!is_dir('/dev/fd') || defined('HHVM_VERSION')) { + $this->markTestSkipped('Not supported on your platform'); + } + + $socket = stream_socket_server('127.0.0.1:0'); + $fd = $this->getFdFromResource($socket); + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('removeReadStream'); + + $server = new FdServer($fd, $loop); + $server->close(); + } + + public function testCloseTwiceRemovesResourceFromLoopOnce() + { + if (!is_dir('/dev/fd') || defined('HHVM_VERSION')) { + $this->markTestSkipped('Not supported on your platform'); + } + + $socket = stream_socket_server('127.0.0.1:0'); + $fd = $this->getFdFromResource($socket); + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('removeReadStream'); + + $server = new FdServer($fd, $loop); + $server->close(); + $server->close(); + } + + public function testResumeWithoutPauseIsNoOp() + { + if (!is_dir('/dev/fd') || defined('HHVM_VERSION')) { + $this->markTestSkipped('Not supported on your platform'); + } + + $socket = stream_socket_server('127.0.0.1:0'); + $fd = $this->getFdFromResource($socket); + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addReadStream'); + + $server = new FdServer($fd, $loop); + $server->resume(); + } + + public function testPauseRemovesResourceFromLoop() + { + if (!is_dir('/dev/fd') || defined('HHVM_VERSION')) { + $this->markTestSkipped('Not supported on your platform'); + } + + $socket = stream_socket_server('127.0.0.1:0'); + $fd = $this->getFdFromResource($socket); + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('removeReadStream'); + + $server = new FdServer($fd, $loop); + $server->pause(); + } + + public function testPauseAfterPauseIsNoOp() + { + if (!is_dir('/dev/fd') || defined('HHVM_VERSION')) { + $this->markTestSkipped('Not supported on your platform'); + } + + $socket = stream_socket_server('127.0.0.1:0'); + $fd = $this->getFdFromResource($socket); + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('removeReadStream'); + + $server = new FdServer($fd, $loop); + $server->pause(); + $server->pause(); + } + + public function testServerEmitsConnectionEventForNewConnection() + { + if (!is_dir('/dev/fd') || defined('HHVM_VERSION')) { + $this->markTestSkipped('Not supported on your platform'); + } + + $socket = stream_socket_server('127.0.0.1:0'); + $fd = $this->getFdFromResource($socket); + + $client = stream_socket_client('tcp://' . stream_socket_get_name($socket, false)); + + $server = new FdServer($fd, Loop::get()); + $promise = new Promise(function ($resolve) use ($server) { + $server->on('connection', $resolve); + }); + + $connection = Block\await($promise, Loop::get(), 1.0); + + $this->assertInstanceOf('React\Socket\ConnectionInterface', $connection); + + fclose($client); + } + + public function testEmitsErrorWhenAcceptListenerFails() + { + if (!is_dir('/dev/fd') || defined('HHVM_VERSION')) { + $this->markTestSkipped('Not supported on your platform'); + } + + $listener = null; + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addReadStream')->with($this->anything(), $this->callback(function ($cb) use (&$listener) { + $listener = $cb; + return true; + })); + + $socket = stream_socket_server('127.0.0.1:0'); + $fd = $this->getFdFromResource($socket); + + $server = new FdServer($fd, $loop); + + $exception = null; + $server->on('error', function ($e) use (&$exception) { + $exception = $e; + }); + + $this->assertNotNull($listener); + $socket = stream_socket_server('tcp://127.0.0.1:0'); + + $time = microtime(true); + $listener($socket); + $time = microtime(true) - $time; + + $this->assertLessThan(1, $time); + + $this->assertInstanceOf('RuntimeException', $exception); + assert($exception instanceof \RuntimeException); + $this->assertStringStartsWith('Unable to accept new connection: ', $exception->getMessage()); + + return $exception; + } + + /** + * @param \RuntimeException $e + * @requires extension sockets + * @depends testEmitsErrorWhenAcceptListenerFails + */ + public function testEmitsTimeoutErrorWhenAcceptListenerFails(\RuntimeException $exception) + { + $this->assertEquals('Unable to accept new connection: ' . socket_strerror(SOCKET_ETIMEDOUT), $exception->getMessage()); + $this->assertEquals(SOCKET_ETIMEDOUT, $exception->getCode()); + } + + /** + * @param resource $resource + * @return int + * @throws \UnexpectedValueException + * @throws \BadMethodCallException + * @throws \UnderflowException + * @copyright Copyright (c) 2018 Christian Lück, taken from https://github.com/clue/fd with permission + */ + private function getFdFromResource($resource) + { + $stat = @fstat($resource); + if (!isset($stat['ino']) || $stat['ino'] === 0) { + throw new \UnexpectedValueException('Could not access inode of given resource (unsupported type or platform)'); + } + + $dir = @scandir('/dev/fd'); + if ($dir === false) { + throw new \BadMethodCallException('Not supported on your platform because /dev/fd is not readable'); + } + + $ino = (int) $stat['ino']; + foreach ($dir as $file) { + $stat = @stat('/dev/fd/' . $file); + if (isset($stat['ino']) && $stat['ino'] === $ino) { + return (int) $file; + } + } + + throw new \UnderflowException('Could not locate file descriptor for this resource'); + } +} From 9909831759acb246462ffd4f0e42def35dcfe168 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sat, 24 Aug 2019 09:33:24 +0200 Subject: [PATCH 2/4] Support listening on Unix domain sockets (UDS) file descriptors (FDs) --- src/FdServer.php | 16 +++++++++++++--- tests/FdServerTest.php | 27 ++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/FdServer.php b/src/FdServer.php index d17ae1f1..ecb13d1f 100644 --- a/src/FdServer.php +++ b/src/FdServer.php @@ -35,6 +35,7 @@ final class FdServer extends EventEmitter implements ServerInterface { private $master; private $loop; + private $unix = false; private $listening = false; /** @@ -115,6 +116,10 @@ public function __construct($fd, LoopInterface $loop = null) throw new \RuntimeException('Failed to listen on FD ' . $fd . ': ' . $errstr, $errno); } + // Assume this is a Unix domain socket (UDS) when its listening address doesn't parse as a valid URL with a port. + // Looks like this work-around is the closest we can get because PHP doesn't expose SO_DOMAIN even with ext-sockets. + $this->unix = \parse_url($this->getAddress(), \PHP_URL_PORT) === false; + \stream_set_blocking($this->master, false); $this->resume(); @@ -128,6 +133,10 @@ public function getAddress() $address = \stream_socket_get_name($this->master, false); + if ($this->unix === true) { + return 'unix://' . $address; + } + // check if this is an IPv6 address which includes multiple colons but no square brackets $pos = \strrpos($address, ':'); if ($pos !== false && \strpos($address, ':') < $pos && \substr($address, 0, 1) !== '[') { @@ -180,8 +189,9 @@ public function close() /** @internal */ public function handleConnection($socket) { - $this->emit('connection', array( - new Connection($socket, $this->loop) - )); + $connection = new Connection($socket, $this->loop); + $connection->unix = $this->unix; + + $this->emit('connection', array($connection)); } } diff --git a/tests/FdServerTest.php b/tests/FdServerTest.php index c8c9098c..03ce5bcb 100644 --- a/tests/FdServerTest.php +++ b/tests/FdServerTest.php @@ -120,7 +120,7 @@ public function testGetAddressReturnsSameAddressAsOriginalSocketForIpv6Socket() $socket = @stream_socket_server('[::1]:0'); if ($socket === false) { - $this->markTestSkipped('IPv6 not supported '); + $this->markTestSkipped('Listening on IPv6 not supported'); } $fd = $this->getFdFromResource($socket); @@ -133,6 +133,26 @@ public function testGetAddressReturnsSameAddressAsOriginalSocketForIpv6Socket() $this->assertEquals('tcp://[::1]:' . $port, $server->getAddress()); } + public function testGetAddressReturnsSameAddressAsOriginalSocketForUnixDomainSocket() + { + if (!is_dir('/dev/fd') || defined('HHVM_VERSION')) { + $this->markTestSkipped('Not supported on your platform'); + } + + $socket = @stream_socket_server($this->getRandomSocketUri()); + if ($socket === false) { + $this->markTestSkipped('Listening on Unix domain socket (UDS) not supported'); + } + + $fd = $this->getFdFromResource($socket); + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $server = new FdServer($fd, $loop); + + $this->assertEquals('unix://' . stream_socket_get_name($socket, false), $server->getAddress()); + } + public function testGetAddressReturnsNullAfterClose() { if (!is_dir('/dev/fd') || defined('HHVM_VERSION')) { @@ -335,4 +355,9 @@ private function getFdFromResource($resource) throw new \UnderflowException('Could not locate file descriptor for this resource'); } + + private function getRandomSocketUri() + { + return "unix://" . sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid(rand(), true) . '.sock'; + } } From 6aaae223ed8eb52234719e422e220f3e89bf3b25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sat, 21 Aug 2021 10:15:24 +0200 Subject: [PATCH 3/4] Support listening on existing file descriptors (FDs) with `SocketServer` --- README.md | 7 +++++ examples/01-echo-server.php | 4 +++ examples/02-chat-server.php | 4 +++ examples/03-http-server.php | 4 +++ src/FdServer.php | 5 +++- src/SocketServer.php | 2 ++ tests/FdServerTest.php | 57 ++++++++++++++++++++++++++----------- tests/SocketServerTest.php | 16 +++++++++++ 8 files changed, 82 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 934e53c7..4c980df0 100644 --- a/README.md +++ b/README.md @@ -380,6 +380,13 @@ To listen on a Unix domain socket (UDS) path, you MUST prefix the URI with the $socket = new React\Socket\SocketServer('unix:///tmp/server.sock'); ``` +In order to listen on an existing file descriptor (FD) number, you MUST prefix +the URI with `php://fd/` like this: + +```php +$socket = new React\Socket\SocketServer('php://fd/3'); +``` + If the given URI is invalid, does not contain a port, any other scheme or if it contains a hostname, it will throw an `InvalidArgumentException`: diff --git a/examples/01-echo-server.php b/examples/01-echo-server.php index 1ec645de..a690f07a 100644 --- a/examples/01-echo-server.php +++ b/examples/01-echo-server.php @@ -15,6 +15,10 @@ // // $ php examples/01-echo-server.php unix:///tmp/server.sock // $ nc -U /tmp/server.sock +// +// You can also use systemd socket activation and listen on an inherited file descriptor: +// +// $ systemd-socket-activate -l 8000 php examples/01-echo-server.php php://fd/3 require __DIR__ . '/../vendor/autoload.php'; diff --git a/examples/02-chat-server.php b/examples/02-chat-server.php index 9027f28b..3e2f354c 100644 --- a/examples/02-chat-server.php +++ b/examples/02-chat-server.php @@ -15,6 +15,10 @@ // // $ php examples/02-chat-server.php unix:///tmp/server.sock // $ nc -U /tmp/server.sock +// +// You can also use systemd socket activation and listen on an inherited file descriptor: +// +// $ systemd-socket-activate -l 8000 php examples/02-chat-server.php php://fd/3 require __DIR__ . '/../vendor/autoload.php'; diff --git a/examples/03-http-server.php b/examples/03-http-server.php index cc6440fb..09606ab7 100644 --- a/examples/03-http-server.php +++ b/examples/03-http-server.php @@ -28,6 +28,10 @@ // // $ php examples/03-http-server.php unix:///tmp/server.sock // $ nc -U /tmp/server.sock +// +// You can also use systemd socket activation and listen on an inherited file descriptor: +// +// $ systemd-socket-activate -l 8000 php examples/03-http-server.php php://fd/3 require __DIR__ . '/../vendor/autoload.php'; diff --git a/src/FdServer.php b/src/FdServer.php index ecb13d1f..4032d043 100644 --- a/src/FdServer.php +++ b/src/FdServer.php @@ -70,13 +70,16 @@ final class FdServer extends EventEmitter implements ServerInterface * See the exception message and code for more details about the actual error * condition. * - * @param int $fd + * @param int|string $fd FD number such as `3` or as URL in the form of `php://fd/3` * @param ?LoopInterface $loop * @throws \InvalidArgumentException if the listening address is invalid * @throws \RuntimeException if listening on this address fails (already in use etc.) */ public function __construct($fd, LoopInterface $loop = null) { + if (\preg_match('#^php://fd/(\d+)$#', $fd, $m)) { + $fd = (int) $m[1]; + } if (!\is_int($fd) || $fd < 0 || $fd >= \PHP_INT_MAX) { throw new \InvalidArgumentException('Invalid FD number given'); } diff --git a/src/SocketServer.php b/src/SocketServer.php index 88ae6487..fa379732 100644 --- a/src/SocketServer.php +++ b/src/SocketServer.php @@ -48,6 +48,8 @@ public function __construct($uri, array $context = array(), LoopInterface $loop if ($scheme === 'unix') { $server = new UnixServer($uri, $loop, $context['unix']); + } elseif ($scheme === 'php') { + $server = new FdServer($uri, $loop); } else { if (preg_match('#^(?:\w+://)?\d+$#', $uri)) { throw new \InvalidArgumentException('Invalid URI given'); diff --git a/tests/FdServerTest.php b/tests/FdServerTest.php index 03ce5bcb..4b7713f2 100644 --- a/tests/FdServerTest.php +++ b/tests/FdServerTest.php @@ -16,7 +16,7 @@ public function testCtorAddsResourceToLoop() } $socket = stream_socket_server('127.0.0.1:0'); - $fd = $this->getFdFromResource($socket); + $fd = self::getFdFromResource($socket); $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); $loop->expects($this->once())->method('addReadStream'); @@ -33,6 +33,15 @@ public function testCtorThrowsForInvalidFd() new FdServer(-1, $loop); } + public function testCtorThrowsForInvalidUrl() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->never())->method('addReadStream'); + + $this->setExpectedException('InvalidArgumentException'); + new FdServer('tcp://127.0.0.1:8080', $loop); + } + public function testCtorThrowsForUnknownFd() { if (!is_dir('/dev/fd') || defined('HHVM_VERSION')) { @@ -40,7 +49,7 @@ public function testCtorThrowsForUnknownFd() } $socket = stream_socket_server('127.0.0.1:0'); - $fd = $this->getFdFromResource($socket); + $fd = self::getFdFromResource($socket); fclose($socket); $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); @@ -61,7 +70,7 @@ public function testCtorThrowsIfFdIsAFileAndNotASocket() } $tmpfile = tmpfile(); - $fd = $this->getFdFromResource($tmpfile); + $fd = self::getFdFromResource($tmpfile); $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); $loop->expects($this->never())->method('addReadStream'); @@ -83,7 +92,7 @@ public function testCtorThrowsIfFdIsAConnectedSocketInsteadOfServerSocket() $socket = stream_socket_server('tcp://127.0.0.1:0'); $client = stream_socket_client('tcp://' . stream_socket_get_name($socket, false)); - $fd = $this->getFdFromResource($client); + $fd = self::getFdFromResource($client); $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); $loop->expects($this->never())->method('addReadStream'); @@ -103,7 +112,7 @@ public function testGetAddressReturnsSameAddressAsOriginalSocketForIpv4Socket() } $socket = stream_socket_server('127.0.0.1:0'); - $fd = $this->getFdFromResource($socket); + $fd = self::getFdFromResource($socket); $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); @@ -112,6 +121,22 @@ public function testGetAddressReturnsSameAddressAsOriginalSocketForIpv4Socket() $this->assertEquals('tcp://' . stream_socket_get_name($socket, false), $server->getAddress()); } + public function testGetAddressReturnsSameAddressAsOriginalSocketForIpv4SocketGivenAsUrlToFd() + { + if (!is_dir('/dev/fd') || defined('HHVM_VERSION')) { + $this->markTestSkipped('Not supported on your platform'); + } + + $socket = stream_socket_server('127.0.0.1:0'); + $fd = self::getFdFromResource($socket); + + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + + $server = new FdServer('php://fd/' . $fd, $loop); + + $this->assertEquals('tcp://' . stream_socket_get_name($socket, false), $server->getAddress()); + } + public function testGetAddressReturnsSameAddressAsOriginalSocketForIpv6Socket() { if (!is_dir('/dev/fd') || defined('HHVM_VERSION')) { @@ -123,7 +148,7 @@ public function testGetAddressReturnsSameAddressAsOriginalSocketForIpv6Socket() $this->markTestSkipped('Listening on IPv6 not supported'); } - $fd = $this->getFdFromResource($socket); + $fd = self::getFdFromResource($socket); $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); @@ -144,7 +169,7 @@ public function testGetAddressReturnsSameAddressAsOriginalSocketForUnixDomainSoc $this->markTestSkipped('Listening on Unix domain socket (UDS) not supported'); } - $fd = $this->getFdFromResource($socket); + $fd = self::getFdFromResource($socket); $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); @@ -160,7 +185,7 @@ public function testGetAddressReturnsNullAfterClose() } $socket = stream_socket_server('127.0.0.1:0'); - $fd = $this->getFdFromResource($socket); + $fd = self::getFdFromResource($socket); $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); @@ -177,7 +202,7 @@ public function testCloseRemovesResourceFromLoop() } $socket = stream_socket_server('127.0.0.1:0'); - $fd = $this->getFdFromResource($socket); + $fd = self::getFdFromResource($socket); $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); $loop->expects($this->once())->method('removeReadStream'); @@ -193,7 +218,7 @@ public function testCloseTwiceRemovesResourceFromLoopOnce() } $socket = stream_socket_server('127.0.0.1:0'); - $fd = $this->getFdFromResource($socket); + $fd = self::getFdFromResource($socket); $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); $loop->expects($this->once())->method('removeReadStream'); @@ -210,7 +235,7 @@ public function testResumeWithoutPauseIsNoOp() } $socket = stream_socket_server('127.0.0.1:0'); - $fd = $this->getFdFromResource($socket); + $fd = self::getFdFromResource($socket); $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); $loop->expects($this->once())->method('addReadStream'); @@ -226,7 +251,7 @@ public function testPauseRemovesResourceFromLoop() } $socket = stream_socket_server('127.0.0.1:0'); - $fd = $this->getFdFromResource($socket); + $fd = self::getFdFromResource($socket); $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); $loop->expects($this->once())->method('removeReadStream'); @@ -242,7 +267,7 @@ public function testPauseAfterPauseIsNoOp() } $socket = stream_socket_server('127.0.0.1:0'); - $fd = $this->getFdFromResource($socket); + $fd = self::getFdFromResource($socket); $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); $loop->expects($this->once())->method('removeReadStream'); @@ -259,7 +284,7 @@ public function testServerEmitsConnectionEventForNewConnection() } $socket = stream_socket_server('127.0.0.1:0'); - $fd = $this->getFdFromResource($socket); + $fd = self::getFdFromResource($socket); $client = stream_socket_client('tcp://' . stream_socket_get_name($socket, false)); @@ -289,7 +314,7 @@ public function testEmitsErrorWhenAcceptListenerFails() })); $socket = stream_socket_server('127.0.0.1:0'); - $fd = $this->getFdFromResource($socket); + $fd = self::getFdFromResource($socket); $server = new FdServer($fd, $loop); @@ -333,7 +358,7 @@ public function testEmitsTimeoutErrorWhenAcceptListenerFails(\RuntimeException $ * @throws \UnderflowException * @copyright Copyright (c) 2018 Christian Lück, taken from https://github.com/clue/fd with permission */ - private function getFdFromResource($resource) + public static function getFdFromResource($resource) { $stat = @fstat($resource); if (!isset($stat['ino']) || $stat['ino'] === 0) { diff --git a/tests/SocketServerTest.php b/tests/SocketServerTest.php index 536709a6..6b5edb15 100644 --- a/tests/SocketServerTest.php +++ b/tests/SocketServerTest.php @@ -17,6 +17,7 @@ class SocketServerTest extends TestCase public function testConstructWithoutLoopAssignsLoopAutomatically() { $socket = new SocketServer('127.0.0.1:0'); + $socket->close(); $ref = new \ReflectionProperty($socket, 'server'); $ref->setAccessible(true); @@ -117,6 +118,21 @@ public function testConstructorThrowsForExistingUnixPath() } } + public function testConstructWithExistingFileDescriptorReturnsSameAddressAsOriginalSocketForIpv4Socket() + { + if (!is_dir('/dev/fd') || defined('HHVM_VERSION')) { + $this->markTestSkipped('Not supported on your platform'); + } + + $socket = stream_socket_server('127.0.0.1:0'); + $fd = FdServerTest::getFdFromResource($socket); + + $server = new SocketServer('php://fd/' . $fd); + $server->pause(); + + $this->assertEquals('tcp://' . stream_socket_get_name($socket, false), $server->getAddress()); + } + public function testEmitsErrorWhenUnderlyingTcpServerEmitsError() { $loop = Factory::create(); From 65dfe45fa1871bb4495a897b01a052035ff2735b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Tue, 24 Aug 2021 12:34:11 +0200 Subject: [PATCH 4/4] Update test suite to find next free file descriptor in advance (Mac) --- tests/FdServerTest.php | 60 +++++++++++++++++++++----------------- tests/SocketServerTest.php | 2 +- 2 files changed, 34 insertions(+), 28 deletions(-) diff --git a/tests/FdServerTest.php b/tests/FdServerTest.php index 4b7713f2..b23231e4 100644 --- a/tests/FdServerTest.php +++ b/tests/FdServerTest.php @@ -15,8 +15,9 @@ public function testCtorAddsResourceToLoop() $this->markTestSkipped('Not supported on your platform'); } + $fd = self::getNextFreeFd(); $socket = stream_socket_server('127.0.0.1:0'); - $fd = self::getFdFromResource($socket); + assert($socket !== false); $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); $loop->expects($this->once())->method('addReadStream'); @@ -48,9 +49,7 @@ public function testCtorThrowsForUnknownFd() $this->markTestSkipped('Not supported on your platform'); } - $socket = stream_socket_server('127.0.0.1:0'); - $fd = self::getFdFromResource($socket); - fclose($socket); + $fd = self::getNextFreeFd(); $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); $loop->expects($this->never())->method('addReadStream'); @@ -69,8 +68,9 @@ public function testCtorThrowsIfFdIsAFileAndNotASocket() $this->markTestSkipped('Not supported on your platform'); } + $fd = self::getNextFreeFd(); $tmpfile = tmpfile(); - $fd = self::getFdFromResource($tmpfile); + assert($tmpfile !== false); $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); $loop->expects($this->never())->method('addReadStream'); @@ -90,9 +90,10 @@ public function testCtorThrowsIfFdIsAConnectedSocketInsteadOfServerSocket() } $socket = stream_socket_server('tcp://127.0.0.1:0'); - $client = stream_socket_client('tcp://' . stream_socket_get_name($socket, false)); - $fd = self::getFdFromResource($client); + $fd = self::getNextFreeFd(); + $client = stream_socket_client('tcp://' . stream_socket_get_name($socket, false)); + assert($client !== false); $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); $loop->expects($this->never())->method('addReadStream'); @@ -111,8 +112,8 @@ public function testGetAddressReturnsSameAddressAsOriginalSocketForIpv4Socket() $this->markTestSkipped('Not supported on your platform'); } + $fd = self::getNextFreeFd(); $socket = stream_socket_server('127.0.0.1:0'); - $fd = self::getFdFromResource($socket); $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); @@ -127,8 +128,8 @@ public function testGetAddressReturnsSameAddressAsOriginalSocketForIpv4SocketGiv $this->markTestSkipped('Not supported on your platform'); } + $fd = self::getNextFreeFd(); $socket = stream_socket_server('127.0.0.1:0'); - $fd = self::getFdFromResource($socket); $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); @@ -143,13 +144,12 @@ public function testGetAddressReturnsSameAddressAsOriginalSocketForIpv6Socket() $this->markTestSkipped('Not supported on your platform'); } + $fd = self::getNextFreeFd(); $socket = @stream_socket_server('[::1]:0'); if ($socket === false) { $this->markTestSkipped('Listening on IPv6 not supported'); } - $fd = self::getFdFromResource($socket); - $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); $server = new FdServer($fd, $loop); @@ -164,13 +164,12 @@ public function testGetAddressReturnsSameAddressAsOriginalSocketForUnixDomainSoc $this->markTestSkipped('Not supported on your platform'); } + $fd = self::getNextFreeFd(); $socket = @stream_socket_server($this->getRandomSocketUri()); if ($socket === false) { $this->markTestSkipped('Listening on Unix domain socket (UDS) not supported'); } - $fd = self::getFdFromResource($socket); - $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); $server = new FdServer($fd, $loop); @@ -184,8 +183,9 @@ public function testGetAddressReturnsNullAfterClose() $this->markTestSkipped('Not supported on your platform'); } + $fd = self::getNextFreeFd(); $socket = stream_socket_server('127.0.0.1:0'); - $fd = self::getFdFromResource($socket); + assert($socket !== false); $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); @@ -201,8 +201,9 @@ public function testCloseRemovesResourceFromLoop() $this->markTestSkipped('Not supported on your platform'); } + $fd = self::getNextFreeFd(); $socket = stream_socket_server('127.0.0.1:0'); - $fd = self::getFdFromResource($socket); + assert($socket !== false); $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); $loop->expects($this->once())->method('removeReadStream'); @@ -217,8 +218,9 @@ public function testCloseTwiceRemovesResourceFromLoopOnce() $this->markTestSkipped('Not supported on your platform'); } + $fd = self::getNextFreeFd(); $socket = stream_socket_server('127.0.0.1:0'); - $fd = self::getFdFromResource($socket); + assert($socket !== false); $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); $loop->expects($this->once())->method('removeReadStream'); @@ -234,8 +236,9 @@ public function testResumeWithoutPauseIsNoOp() $this->markTestSkipped('Not supported on your platform'); } + $fd = self::getNextFreeFd(); $socket = stream_socket_server('127.0.0.1:0'); - $fd = self::getFdFromResource($socket); + assert($socket !== false); $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); $loop->expects($this->once())->method('addReadStream'); @@ -250,8 +253,9 @@ public function testPauseRemovesResourceFromLoop() $this->markTestSkipped('Not supported on your platform'); } + $fd = self::getNextFreeFd(); $socket = stream_socket_server('127.0.0.1:0'); - $fd = self::getFdFromResource($socket); + assert($socket !== false); $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); $loop->expects($this->once())->method('removeReadStream'); @@ -266,8 +270,9 @@ public function testPauseAfterPauseIsNoOp() $this->markTestSkipped('Not supported on your platform'); } + $fd = self::getNextFreeFd(); $socket = stream_socket_server('127.0.0.1:0'); - $fd = self::getFdFromResource($socket); + assert($socket !== false); $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); $loop->expects($this->once())->method('removeReadStream'); @@ -283,8 +288,9 @@ public function testServerEmitsConnectionEventForNewConnection() $this->markTestSkipped('Not supported on your platform'); } + $fd = self::getNextFreeFd(); $socket = stream_socket_server('127.0.0.1:0'); - $fd = self::getFdFromResource($socket); + assert($socket !== false); $client = stream_socket_client('tcp://' . stream_socket_get_name($socket, false)); @@ -313,8 +319,9 @@ public function testEmitsErrorWhenAcceptListenerFails() return true; })); + $fd = self::getNextFreeFd(); $socket = stream_socket_server('127.0.0.1:0'); - $fd = self::getFdFromResource($socket); + assert($socket !== false); $server = new FdServer($fd, $loop); @@ -351,26 +358,25 @@ public function testEmitsTimeoutErrorWhenAcceptListenerFails(\RuntimeException $ } /** - * @param resource $resource * @return int * @throws \UnexpectedValueException * @throws \BadMethodCallException * @throws \UnderflowException * @copyright Copyright (c) 2018 Christian Lück, taken from https://github.com/clue/fd with permission */ - public static function getFdFromResource($resource) + public static function getNextFreeFd() { - $stat = @fstat($resource); - if (!isset($stat['ino']) || $stat['ino'] === 0) { - throw new \UnexpectedValueException('Could not access inode of given resource (unsupported type or platform)'); - } + // open tmpfile to occupy next free FD temporarily + $tmp = tmpfile(); $dir = @scandir('/dev/fd'); if ($dir === false) { throw new \BadMethodCallException('Not supported on your platform because /dev/fd is not readable'); } + $stat = fstat($tmp); $ino = (int) $stat['ino']; + foreach ($dir as $file) { $stat = @stat('/dev/fd/' . $file); if (isset($stat['ino']) && $stat['ino'] === $ino) { diff --git a/tests/SocketServerTest.php b/tests/SocketServerTest.php index 6b5edb15..7092b8b4 100644 --- a/tests/SocketServerTest.php +++ b/tests/SocketServerTest.php @@ -124,8 +124,8 @@ public function testConstructWithExistingFileDescriptorReturnsSameAddressAsOrigi $this->markTestSkipped('Not supported on your platform'); } + $fd = FdServerTest::getNextFreeFd(); $socket = stream_socket_server('127.0.0.1:0'); - $fd = FdServerTest::getFdFromResource($socket); $server = new SocketServer('php://fd/' . $fd); $server->pause();