Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support listening on existing file descriptors (FDs) with SocketServer #269

Merged
merged 4 commits into from
Aug 29, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:

Expand Down
4 changes: 4 additions & 0 deletions examples/01-echo-server.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
4 changes: 4 additions & 0 deletions examples/02-chat-server.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
4 changes: 4 additions & 0 deletions examples/03-http-server.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
200 changes: 200 additions & 0 deletions src/FdServer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
<?php

namespace React\Socket;

use Evenement\EventEmitter;
use React\EventLoop\Loop;
use React\EventLoop\LoopInterface;

/**
* [Internal] The `FdServer` class implements the `ServerInterface` and
* is responsible for accepting connections from an existing file descriptor.
*
* ```php
* $socket = new React\Socket\FdServer(3);
* ```
*
* Whenever a client connects, it will emit a `connection` event with a connection
* instance implementing `ConnectionInterface`:
*
* ```php
* $socket->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 $unix = false;
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|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');
}

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

// 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();
}

public function getAddress()
{
if (!\is_resource($this->master)) {
return null;
}

$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) !== '[') {
$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)
{
$connection = new Connection($socket, $this->loop);
$connection->unix = $this->unix;

$this->emit('connection', array($connection));
}
}
2 changes: 2 additions & 0 deletions src/SocketServer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading