From b167381f6000336ad566fff9349b2ef3264face4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ha=C5=82as?= Date: Fri, 1 May 2026 21:24:24 +0200 Subject: [PATCH 1/2] feat: add Socket wrapper class --- src/Server/Socket/Connection.php | 78 ++++++++++++++++ src/Server/Socket/Socket.php | 77 +++++++++++++++ tests/Server/Socket/SocketTest.php | 144 +++++++++++++++++++++++++++++ 3 files changed, 299 insertions(+) create mode 100644 src/Server/Socket/Connection.php create mode 100644 src/Server/Socket/Socket.php create mode 100644 tests/Server/Socket/SocketTest.php diff --git a/src/Server/Socket/Connection.php b/src/Server/Socket/Connection.php new file mode 100644 index 0000000..6c78f3e --- /dev/null +++ b/src/Server/Socket/Connection.php @@ -0,0 +1,78 @@ +lastActivity = \microtime(true); + } + + public function read(int $length = 65536): string|false + { + if ($this->closed) { + return false; + } + + $data = \socket_read($this->resource, $length); + $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)); + $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 + { + \socket_getpeername($this->resource, $addr, $port); + + /** @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; + } + + \socket_set_option($this->resource, $level, $option, $value); + } +} diff --git a/src/Server/Socket/Socket.php b/src/Server/Socket/Socket.php new file mode 100644 index 0000000..73170be --- /dev/null +++ b/src/Server/Socket/Socket.php @@ -0,0 +1,77 @@ +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'); + } + + \socket_set_option($this->resource, $level, $option, $value); + } + + 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..42defdc --- /dev/null +++ b/tests/Server/Socket/SocketTest.php @@ -0,0 +1,144 @@ +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_set_option($resource, SOL_SOCKET, SO_REUSEADDR, 1); + \socket_bind($resource, '127.0.0.1', 0); + \socket_getsockname($resource, $addr, $port); + $this->assertIsString($addr); + $this->assertIsInt($port); + + \socket_listen($resource); + $socket = new Socket($resource); + + $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(); + } + + 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); + } +} From 71e8fa2d97780f7646741fc04eb849cfa43eba2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ha=C5=82as?= Date: Fri, 1 May 2026 21:34:48 +0200 Subject: [PATCH 2/2] fix: address review comments for Connection and Socket --- src/Server/Socket/Connection.php | 24 ++++++++++++++++++++---- src/Server/Socket/Socket.php | 6 +++++- tests/Server/Socket/SocketTest.php | 14 +++++++++----- 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src/Server/Socket/Connection.php b/src/Server/Socket/Connection.php index 6c78f3e..2382f59 100644 --- a/src/Server/Socket/Connection.php +++ b/src/Server/Socket/Connection.php @@ -21,7 +21,10 @@ public function read(int $length = 65536): string|false } $data = \socket_read($this->resource, $length); - $this->lastActivity = \microtime(true); + + if ($data !== false) { + $this->lastActivity = \microtime(true); + } return $data; } @@ -33,7 +36,10 @@ public function write(string $data): int|false } $result = \socket_write($this->resource, $data, strlen($data)); - $this->lastActivity = \microtime(true); + + if ($result !== false) { + $this->lastActivity = \microtime(true); + } return $result; } @@ -54,7 +60,13 @@ public function isClosed(): bool /** @return array{host: string, port: int} */ public function getPeerName(): array { - \socket_getpeername($this->resource, $addr, $port); + 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 */ @@ -73,6 +85,10 @@ public function setOption(int $level, int $option, array|int|string $value): voi return; } - \socket_set_option($this->resource, $level, $option, $value); + 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 index 73170be..862b0a5 100644 --- a/src/Server/Socket/Socket.php +++ b/src/Server/Socket/Socket.php @@ -46,7 +46,11 @@ public function setOption(int $level, int $option, array|int|string $value): voi throw new SocketCreationException('Socket is closed'); } - \socket_set_option($this->resource, $level, $option, $value); + 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 diff --git a/tests/Server/Socket/SocketTest.php b/tests/Server/Socket/SocketTest.php index 42defdc..ad16243 100644 --- a/tests/Server/Socket/SocketTest.php +++ b/tests/Server/Socket/SocketTest.php @@ -43,15 +43,15 @@ public function testAcceptReturnsConnection(): void $resource = \socket_create(AF_INET, SOCK_STREAM, SOL_TCP); $this->assertNotFalse($resource); - \socket_set_option($resource, SOL_SOCKET, SO_REUSEADDR, 1); - \socket_bind($resource, '127.0.0.1', 0); + $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); - \socket_listen($resource); - $socket = new Socket($resource); - $client = \socket_create(AF_INET, SOCK_STREAM, SOL_TCP); $this->assertNotFalse($client); @@ -75,6 +75,10 @@ public function testCloseIsIdempotent(): void $socket->close(); $socket->close(); + + $reflection = new ReflectionProperty(Socket::class, 'resource'); + + $this->assertNull($reflection->getValue($socket)); } public function testCloseSetsResourceToNull(): void