diff --git a/src/Server/Socket/Connection.php b/src/Server/Socket/Connection.php new file mode 100644 index 0000000..2382f59 --- /dev/null +++ b/src/Server/Socket/Connection.php @@ -0,0 +1,94 @@ +lastActivity = \microtime(true); + } + + public function read(int $length = 65536): string|false + { + if ($this->closed) { + return false; + } + + $data = \socket_read($this->resource, $length); + + if ($data !== false) { + $this->lastActivity = \microtime(true); + } + + return $data; + } + + public function write(string $data): int|false + { + if ($this->closed) { + return false; + } + + $result = \socket_write($this->resource, $data, strlen($data)); + + if ($result !== false) { + $this->lastActivity = \microtime(true); + } + + return $result; + } + + public function close(): void + { + if (!$this->closed) { + \socket_close($this->resource); + $this->closed = true; + } + } + + public function isClosed(): bool + { + return $this->closed; + } + + /** @return array{host: string, port: int} */ + public function getPeerName(): array + { + if ($this->closed) { + throw new \RuntimeException('Connection is closed'); + } + + if (!\socket_getpeername($this->resource, $addr, $port)) { + throw new \RuntimeException('Failed to get peer name'); + } + + /** @var string $addr */ + /** @var int $port */ + return ['host' => $addr, 'port' => $port]; + } + + public function getLastActivity(): float + { + return $this->lastActivity; + } + + /** @param array|int|string $value */ + public function setOption(int $level, int $option, array|int|string $value): void + { + if ($this->closed) { + return; + } + + if (\socket_set_option($this->resource, $level, $option, $value) === false) { + throw new \RuntimeException( + \socket_strerror(\socket_last_error($this->resource)), + ); + } + } +} diff --git a/src/Server/Socket/Socket.php b/src/Server/Socket/Socket.php new file mode 100644 index 0000000..862b0a5 --- /dev/null +++ b/src/Server/Socket/Socket.php @@ -0,0 +1,81 @@ +resource instanceof \Socket) { + throw new SocketAcceptException('Socket is closed'); + } + + $accepted = @\socket_accept($this->resource); + + if ($accepted === false) { + throw new SocketAcceptException( + \socket_strerror(\socket_last_error($this->resource)), + ); + } + + return new Connection($accepted); + } + + public function close(): void + { + if ($this->resource instanceof \Socket) { + \socket_close($this->resource); + } + + $this->resource = null; + } + + /** @param array|int|string $value */ + public function setOption(int $level, int $option, array|int|string $value): void + { + if (!$this->resource instanceof \Socket) { + throw new SocketCreationException('Socket is closed'); + } + + if (\socket_set_option($this->resource, $level, $option, $value) === false) { + throw new SocketCreationException( + \socket_strerror(\socket_last_error($this->resource)), + ); + } + } + + public function bind(string $address, int $port): void + { + if (!$this->resource instanceof \Socket) { + throw new SocketCreationException('Socket is closed'); + } + + if (!\socket_bind($this->resource, $address, $port)) { + throw new SocketCreationException( + \socket_strerror(\socket_last_error($this->resource)), + ); + } + } + + public function listen(int $backlog = SOMAXCONN): void + { + if (!$this->resource instanceof \Socket) { + throw new SocketCreationException('Socket is closed'); + } + + if (!\socket_listen($this->resource, $backlog)) { + throw new SocketCreationException( + \socket_strerror(\socket_last_error($this->resource)), + ); + } + } +} diff --git a/tests/Server/Socket/SocketTest.php b/tests/Server/Socket/SocketTest.php new file mode 100644 index 0000000..ad16243 --- /dev/null +++ b/tests/Server/Socket/SocketTest.php @@ -0,0 +1,148 @@ +assertNotFalse($resource); + + $socket = new Socket($resource); + + $this->assertInstanceOf(Socket::class, $socket); + + \socket_close($resource); + } + + public function testBindAndListen(): void + { + $resource = \socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + $this->assertNotFalse($resource); + + $socket = new Socket($resource); + $socket->setOption(SOL_SOCKET, SO_REUSEADDR, 1); + $socket->bind('127.0.0.1', 0); + $socket->listen(5); + + $socket->close(); + } + + public function testAcceptReturnsConnection(): void + { + $resource = \socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + $this->assertNotFalse($resource); + + $socket = new Socket($resource); + $socket->setOption(SOL_SOCKET, SO_REUSEADDR, 1); + $socket->bind('127.0.0.1', 0); + $socket->listen(); + + \socket_getsockname($resource, $addr, $port); + $this->assertIsString($addr); + $this->assertIsInt($port); + + $client = \socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + $this->assertNotFalse($client); + + \socket_connect($client, $addr, $port); + + $connection = $socket->accept(); + + $this->assertInstanceOf(Connection::class, $connection); + + \socket_close($client); + $connection->close(); + $socket->close(); + } + + public function testCloseIsIdempotent(): void + { + $resource = \socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + $this->assertNotFalse($resource); + + $socket = new Socket($resource); + + $socket->close(); + $socket->close(); + + $reflection = new ReflectionProperty(Socket::class, 'resource'); + + $this->assertNull($reflection->getValue($socket)); + } + + public function testCloseSetsResourceToNull(): void + { + $resource = \socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + $this->assertNotFalse($resource); + + $socket = new Socket($resource); + $socket->close(); + + $reflection = new ReflectionProperty(Socket::class, 'resource'); + + $this->assertNull($reflection->getValue($socket)); + } + + public function testAcceptThrowsAfterClose(): void + { + $resource = \socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + $this->assertNotFalse($resource); + + $socket = new Socket($resource); + $socket->close(); + + $this->expectException(SocketAcceptException::class); + + $socket->accept(); + } + + public function testBindThrowsAfterClose(): void + { + $resource = \socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + $this->assertNotFalse($resource); + + $socket = new Socket($resource); + $socket->close(); + + $this->expectException(SocketCreationException::class); + + $socket->bind('127.0.0.1', 8080); + } + + public function testListenThrowsAfterClose(): void + { + $resource = \socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + $this->assertNotFalse($resource); + + $socket = new Socket($resource); + $socket->close(); + + $this->expectException(SocketCreationException::class); + + $socket->listen(); + } + + public function testSetOptionThrowsAfterClose(): void + { + $resource = \socket_create(AF_INET, SOCK_STREAM, SOL_TCP); + $this->assertNotFalse($resource); + + $socket = new Socket($resource); + $socket->close(); + + $this->expectException(SocketCreationException::class); + + $socket->setOption(SOL_SOCKET, SO_REUSEADDR, 1); + } +}