diff --git a/README.md b/README.md index 2cfb1d93..344f0f0c 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ handle multiple concurrent connections without blocking. * [Advanced server usage](#advanced-server-usage) * [TcpServer](#tcpserver) * [SecureServer](#secureserver) + * [UnixServer](#unixserver) * [LimitingServer](#limitingserver) * [getConnections()](#getconnections) * [Client usage](#client-usage) @@ -255,7 +256,8 @@ If the address can not be determined or is unknown at this time (such as after the socket has been closed), it MAY return a `NULL` value instead. Otherwise, it will return the full address (URI) as a string value, such -as `tcp://127.0.0.1:8080`, `tcp://[::1]:80` or `tls://127.0.0.1:443`. +as `tcp://127.0.0.1:8080`, `tcp://[::1]:80`, `tls://127.0.0.1:443` +`unix://example.sock` or `unix:///path/to/example.sock`. Note that individual URI components are application specific and depend on the underlying transport protocol. @@ -342,6 +344,7 @@ Calling this method more than once on the same instance is a NO-OP. The `Server` class is the main class in this package that implements the [`ServerInterface`](#serverinterface) and allows you to accept incoming streaming connections, such as plaintext TCP/IP or secure TLS connection streams. +Connections can also be accepted on Unix domain sockets. ```php $server = new Server(8080, $loop); @@ -373,6 +376,13 @@ brackets: $server = new Server('[::1]:8080', $loop); ``` +To listen on a Unix domain socket (UDS) path, you MUST prefix the URI with the +`unix://` scheme: + +```php +$server = new Server('unix:///tmp/server.sock', $loop); +``` + 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`: @@ -648,6 +658,43 @@ If you use a custom `ServerInterface` and its `connection` event does not meet this requirement, the `SecureServer` will emit an `error` event and then close the underlying connection. +#### UnixServer + +The `UnixServer` class implements the [`ServerInterface`](#serverinterface) and +is responsible for accepting connections on Unix domain sockets (UDS). + +```php +$server = new UnixServer('/tmp/server.sock', $loop); +``` + +As above, the `$uri` parameter can consist of only a socket path or socket path +prefixed by the `unix://` scheme. + +If the given URI appears to be valid, but listening on it fails (such as if the +socket is already in use or the file not accessible etc.), it will throw a +`RuntimeException`: + +```php +$first = new UnixServer('/tmp/same.sock', $loop); + +// throws RuntimeException because socket is already in use +$second = new UnixServer('/tmp/same.sock', $loop); +``` + +Whenever a client connects, it will emit a `connection` event with a connection +instance implementing [`ConnectionInterface`](#connectioninterface): + +```php +$server->on('connection', function (ConnectionInterface $connection) { + echo 'New connection' . PHP_EOL; + + $connection->write('hello there!' . PHP_EOL); + … +}); +``` + +See also the [`ServerInterface`](#serverinterface) for more details. + #### LimitingServer The `LimitingServer` decorator wraps a given `ServerInterface` and is responsible diff --git a/examples/01-echo.php b/examples/01-echo.php index 18bf9d3b..e6dfffda 100644 --- a/examples/01-echo.php +++ b/examples/01-echo.php @@ -10,6 +10,11 @@ // // $ php examples/01-echo.php tls://127.0.0.1:8000 examples/localhost.pem // $ openssl s_client -connect localhost:8000 +// +// You can also run a Unix domain socket (UDS) server like this: +// +// $ php examples/01-echo.php unix:///tmp/server.sock +// $ nc -U /tmp/server.sock use React\EventLoop\Factory; use React\Socket\Server; diff --git a/examples/02-chat-server.php b/examples/02-chat-server.php index 43d0c576..46439e04 100644 --- a/examples/02-chat-server.php +++ b/examples/02-chat-server.php @@ -10,6 +10,11 @@ // // $ php examples/02-chat-server.php tls://127.0.0.1:8000 examples/localhost.pem // $ openssl s_client -connect localhost:8000 +// +// You can also run a Unix domain socket (UDS) server like this: +// +// $ php examples/02-chat-server.php unix:///tmp/server.sock +// $ nc -U /tmp/server.sock use React\EventLoop\Factory; use React\Socket\Server; diff --git a/examples/03-benchmark.php b/examples/03-benchmark.php index 8f71707a..e8fbfac5 100644 --- a/examples/03-benchmark.php +++ b/examples/03-benchmark.php @@ -6,8 +6,8 @@ // // $ php examples/03-benchmark.php 8000 // $ telnet localhost 8000 -// $ echo hello world | nc -v localhost 8000 -// $ dd if=/dev/zero bs=1M count=1000 | nc -v localhost 8000 +// $ echo hello world | nc -N localhost 8000 +// $ dd if=/dev/zero bs=1M count=1000 | nc -N localhost 8000 // // You can also run a secure TLS benchmarking server like this: // @@ -15,6 +15,12 @@ // $ openssl s_client -connect localhost:8000 // $ echo hello world | openssl s_client -connect localhost:8000 // $ dd if=/dev/zero bs=1M count=1000 | openssl s_client -connect localhost:8000 +// +// You can also run a Unix domain socket (UDS) server benchmark like this: +// +// $ php examples/03-benchmark.php unix:///tmp/server.sock +// $ nc -N -U /tmp/server.sock +// $ dd if=/dev/zero bs=1M count=1000 | nc -N -U /tmp/server.sock use React\EventLoop\Factory; use React\Socket\Server; diff --git a/src/Server.php b/src/Server.php index 86601f78..8c46e1ef 100644 --- a/src/Server.php +++ b/src/Server.php @@ -12,7 +12,7 @@ final class Server extends EventEmitter implements ServerInterface public function __construct($uri, LoopInterface $loop, array $context = array()) { // sanitize TCP context options if not properly wrapped - if ($context && (!isset($context['tcp']) && !isset($context['tls']))) { + if ($context && (!isset($context['tcp']) && !isset($context['tls']) && !isset($context['unix']))) { $context = array('tcp' => $context); } @@ -20,6 +20,7 @@ public function __construct($uri, LoopInterface $loop, array $context = array()) $context += array( 'tcp' => array(), 'tls' => array(), + 'unix' => array() ); $scheme = 'tcp'; @@ -28,10 +29,14 @@ public function __construct($uri, LoopInterface $loop, array $context = array()) $scheme = substr($uri, 0, $pos); } - $server = new TcpServer(str_replace('tls://', '', $uri), $loop, $context['tcp']); + if ($scheme === 'unix') { + $server = new UnixServer($uri, $loop, $context['unix']); + } else { + $server = new TcpServer(str_replace('tls://', '', $uri), $loop, $context['tcp']); - if ($scheme === 'tls') { - $server = new SecureServer($server, $loop, $context['tls']); + if ($scheme === 'tls') { + $server = new SecureServer($server, $loop, $context['tls']); + } } $this->server = $server; diff --git a/src/UnixServer.php b/src/UnixServer.php new file mode 100644 index 00000000..6e53cd66 --- /dev/null +++ b/src/UnixServer.php @@ -0,0 +1,130 @@ +loop = $loop; + + if (strpos($path, '://') === false) { + $path = 'unix://' . $path; + } elseif (substr($path, 0, 7) !== 'unix://') { + throw new \InvalidArgumentException('Given URI "' . $path . '" is invalid'); + } + + $this->master = @stream_socket_server( + $path, + $errno, + $errstr, + STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, + stream_context_create(array('socket' => $context)) + ); + if (false === $this->master) { + throw new RuntimeException('Failed to listen on unix domain socket "' . $path . '": ' . $errstr, $errno); + } + stream_set_blocking($this->master, 0); + + $this->resume(); + } + + public function getAddress() + { + if (!is_resource($this->master)) { + return null; + } + + return 'unix://' . stream_socket_get_name($this->master, false); + } + + 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) { + $newSocket = @stream_socket_accept($master); + if (false === $newSocket) { + $that->emit('error', array(new \RuntimeException('Error accepting new connection'))); + + 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) + { + $connection = new Connection($socket, $this->loop); + $connection->unix = true; + + $this->emit('connection', array( + $connection + )); + } +} diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 5c24e6ca..1a5d2a90 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -4,11 +4,15 @@ use React\EventLoop\Factory; use React\Socket\Server; +use React\Socket\TcpConnector; +use React\Socket\UnixConnector; use Clue\React\Block; use React\Socket\ConnectionInterface; class ServerTest extends TestCase { + const TIMEOUT = 0.1; + public function testCreateServer() { $loop = Factory::create(); @@ -26,6 +30,38 @@ public function testConstructorThrowsForInvalidUri() $server = new Server('invalid URI', $loop); } + public function testConstructorCreatesExpectedTcpServer() + { + $loop = Factory::create(); + + $server = new Server(0, $loop); + + $connector = new TcpConnector($loop); + $connector->connect($server->getAddress()) + ->then($this->expectCallableOnce(), $this->expectCallableNever()); + + $connection = Block\await($connector->connect($server->getAddress()), $loop, self::TIMEOUT); + + $connection->close(); + $server->close(); + } + + public function testConstructorCreatesExpectedUnixServer() + { + $loop = Factory::create(); + + $server = new Server($this->getRandomSocketUri(), $loop); + + $connector = new UnixConnector($loop); + $connector->connect($server->getAddress()) + ->then($this->expectCallableOnce(), $this->expectCallableNever()); + + $connection = Block\await($connector->connect($server->getAddress()), $loop, self::TIMEOUT); + + $connection->close(); + $server->close(); + } + public function testEmitsConnectionForNewConnection() { $loop = Factory::create(); @@ -127,4 +163,9 @@ public function testDoesNotEmitSecureConnectionForNewPlainConnection() Block\sleep(0.1, $loop); } + + private function getRandomSocketUri() + { + return "unix://" . sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid(rand(), true) . '.sock'; + } } diff --git a/tests/UnixServerTest.php b/tests/UnixServerTest.php new file mode 100644 index 00000000..51f49601 --- /dev/null +++ b/tests/UnixServerTest.php @@ -0,0 +1,278 @@ +loop = Factory::create(); + $this->uds = $this->getRandomSocketUri(); + $this->server = new UnixServer($this->uds, $this->loop); + } + + /** + * @covers React\Socket\UnixServer::handleConnection + */ + public function testConnection() + { + $client = stream_socket_client($this->uds); + + $this->server->on('connection', $this->expectCallableOnce()); + $this->tick(); + } + + /** + * @covers React\Socket\UnixServer::handleConnection + */ + public function testConnectionWithManyClients() + { + $client1 = stream_socket_client($this->uds); + $client2 = stream_socket_client($this->uds); + $client3 = stream_socket_client($this->uds); + + $this->server->on('connection', $this->expectCallableExactly(3)); + $this->tick(); + $this->tick(); + $this->tick(); + } + + public function testDataEventWillNotBeEmittedWhenClientSendsNoData() + { + $client = stream_socket_client($this->uds); + + $mock = $this->expectCallableNever(); + + $this->server->on('connection', function ($conn) use ($mock) { + $conn->on('data', $mock); + }); + $this->tick(); + $this->tick(); + } + + public function testDataWillBeEmittedWithDataClientSends() + { + $client = stream_socket_client($this->uds); + + fwrite($client, "foo\n"); + + $mock = $this->expectCallableOnceWith("foo\n"); + + $this->server->on('connection', function ($conn) use ($mock) { + $conn->on('data', $mock); + }); + $this->tick(); + $this->tick(); + } + + public function testDataWillBeEmittedEvenWhenClientShutsDownAfterSending() + { + $client = stream_socket_client($this->uds); + fwrite($client, "foo\n"); + stream_socket_shutdown($client, STREAM_SHUT_WR); + + $mock = $this->expectCallableOnceWith("foo\n"); + + $this->server->on('connection', function ($conn) use ($mock) { + $conn->on('data', $mock); + }); + $this->tick(); + $this->tick(); + } + + public function testLoopWillEndWhenServerIsClosed() + { + // explicitly unset server because we already call close() + $this->server->close(); + $this->server = null; + + $this->loop->run(); + } + + public function testCloseTwiceIsNoOp() + { + $this->server->close(); + $this->server->close(); + } + + public function testGetAddressAfterCloseReturnsNull() + { + $this->server->close(); + $this->assertNull($this->server->getAddress()); + } + + public function testLoopWillEndWhenServerIsClosedAfterSingleConnection() + { + $client = stream_socket_client($this->uds); + + // explicitly unset server because we only accept a single connection + // and then already call close() + $server = $this->server; + $this->server = null; + + $server->on('connection', function ($conn) use ($server) { + $conn->close(); + $server->close(); + }); + + $this->loop->run(); + } + + public function testDataWillBeEmittedInMultipleChunksWhenClientSendsExcessiveAmounts() + { + $client = stream_socket_client($this->uds); + $stream = new DuplexResourceStream($client, $this->loop); + + $bytes = 1024 * 1024; + $stream->end(str_repeat('*', $bytes)); + + $mock = $this->expectCallableOnce(); + + // explicitly unset server because we only accept a single connection + // and then already call close() + $server = $this->server; + $this->server = null; + + $received = 0; + $server->on('connection', function ($conn) use ($mock, &$received, $server) { + // count number of bytes received + $conn->on('data', function ($data) use (&$received) { + $received += strlen($data); + }); + + $conn->on('end', $mock); + + // do not await any further connections in order to let the loop terminate + $server->close(); + }); + + $this->loop->run(); + + $this->assertEquals($bytes, $received); + } + + /** + * @covers React\EventLoop\StreamSelectLoop::tick + */ + public function testConnectionDoesNotEndWhenClientDoesNotClose() + { + $client = stream_socket_client($this->uds); + + $mock = $this->expectCallableNever(); + + $this->server->on('connection', function ($conn) use ($mock) { + $conn->on('end', $mock); + }); + $this->tick(); + $this->tick(); + } + + /** + * @covers React\EventLoop\StreamSelectLoop::tick + * @covers React\Socket\Connection::end + */ + public function testConnectionDoesEndWhenClientCloses() + { + $client = stream_socket_client($this->uds); + + fclose($client); + + $mock = $this->expectCallableOnce(); + + $this->server->on('connection', function ($conn) use ($mock) { + $conn->on('end', $mock); + }); + $this->tick(); + $this->tick(); + } + + public function testCtorAddsResourceToLoop() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addReadStream'); + + $server = new UnixServer($this->getRandomSocketUri(), $loop); + } + + public function testResumeWithoutPauseIsNoOp() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('addReadStream'); + + $server = new UnixServer($this->getRandomSocketUri(), $loop); + $server->resume(); + } + + public function testPauseRemovesResourceFromLoop() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('removeReadStream'); + + $server = new UnixServer($this->getRandomSocketUri(), $loop); + $server->pause(); + } + + public function testPauseAfterPauseIsNoOp() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('removeReadStream'); + + $server = new UnixServer($this->getRandomSocketUri(), $loop); + $server->pause(); + $server->pause(); + } + + public function testCloseRemovesResourceFromLoop() + { + $loop = $this->getMockBuilder('React\EventLoop\LoopInterface')->getMock(); + $loop->expects($this->once())->method('removeReadStream'); + + $server = new UnixServer($this->getRandomSocketUri(), $loop); + $server->close(); + } + + /** + * @expectedException RuntimeException + */ + public function testListenOnBusyPortThrows() + { + if (DIRECTORY_SEPARATOR === '\\') { + $this->markTestSkipped('Windows supports listening on same port multiple times'); + } + + $another = new UnixServer($this->uds, $this->loop); + } + + /** + * @covers React\Socket\UnixServer::close + */ + public function tearDown() + { + if ($this->server) { + $this->server->close(); + } + } + + private function getRandomSocketUri() + { + return "unix://" . sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid(rand(), true) . '.sock'; + } + + private function tick() + { + Block\sleep(0, $this->loop); + } +} pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy