From 601a0a2b58b3d4fbadb96f465f108417a57cf663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Sun, 24 May 2026 21:26:02 +0300 Subject: [PATCH 1/3] feat: Add dependabot config, CI workflow, UDP client, chat server docs, PHPUnit config, StreamChannel, TcpChannel, UdpChannel, CryptoMethod, Domain, Transport enums, socket exceptions, interfaces refactoring, RuntimeException, AbstractStreamServer, SSL, ServerConnection, TCP, TLS, UDP classes, unit tests --- .github/dependabot.yml | 24 ++ .github/workflows/ci.yml | 99 ++++++ .gitignore | 7 +- .php-cs-fixer.dist.php | 37 ++ README.md | 333 +++++++++++------- composer.json | 69 +++- docs/architecture.md | 133 +++++++ docs/client/ssl.md | 37 ++ docs/client/tcp.md | 56 +++ docs/client/tls.md | 66 ++++ docs/client/udp.md | 40 +++ docs/cookbook/chat-server.md | 77 ++++ docs/cookbook/smtp-client.md | 71 ++++ docs/getting-started.md | 88 +++++ docs/migration-1.x-to-2.x.md | 135 +++++++ docs/server/ssl.md | 35 ++ docs/server/tcp.md | 112 ++++++ docs/server/tls.md | 78 ++++ docs/server/udp.md | 57 +++ phpstan.neon.dist | 8 + phpunit.xml.dist | 31 ++ src/Channel/StreamChannel.php | 78 ++++ src/Channel/TcpChannel.php | 88 +++++ src/Channel/UdpChannel.php | 99 ++++++ src/Client/AbstractClient.php | 33 ++ src/Client/AbstractStreamClient.php | 172 +++++++++ src/Client/SSL.php | 25 +- src/Client/TCP.php | 105 +++--- src/Client/TLS.php | 25 +- src/Client/UDP.php | 121 +++---- src/Common/BaseClient.php | 32 -- src/Common/BaseCommon.php | 107 ------ src/Common/BaseServer.php | 125 ------- src/Common/ServerTrait.php | 36 -- src/Common/StreamClientTrait.php | 136 ------- src/Common/StreamServerTrait.php | 173 --------- src/Enum/CryptoMethod.php | 78 ++++ src/Enum/Domain.php | 43 +++ src/Enum/Transport.php | 28 ++ src/Exception/SocketConnectionException.php | 13 +- src/Exception/SocketException.php | 15 +- src/Exception/SocketExceptionInterface.php | 11 + .../SocketInvalidArgumentException.php | 15 +- src/Exception/SocketListenException.php | 13 +- src/Interfaces/ChannelInterface.php | 50 +++ src/Interfaces/SocketClientInterface.php | 42 +-- src/Interfaces/SocketConnectionInterface.php | 47 +++ .../SocketServerClientInterface.php | 47 --- src/Interfaces/SocketServerInterface.php | 85 +++-- src/Server/AbstractServer.php | 191 ++++++++++ src/Server/AbstractStreamServer.php | 243 +++++++++++++ src/Server/SSL.php | 25 +- src/Server/ServerClient.php | 195 ---------- src/Server/ServerConnection.php | 59 ++++ src/Server/TCP.php | 209 ++++++++--- src/Server/TLS.php | 25 +- src/Server/UDP.php | 161 +++++++-- src/Socket.php | 127 +++---- tests/Integration/IntegrationTestCase.php | 78 ++++ tests/Integration/TcpEchoTest.php | 97 +++++ tests/Integration/TlsEchoTest.php | 105 ++++++ tests/Integration/UdpEchoTest.php | 81 +++++ tests/Unit/Channel/UdpChannelTest.php | 62 ++++ tests/Unit/Enum/CryptoMethodTest.php | 34 ++ tests/Unit/Enum/DomainTest.php | 40 +++ tests/Unit/Enum/TransportTest.php | 44 +++ tests/Unit/Exception/HierarchyTest.php | 34 ++ .../Server/AbstractServerBroadcastTest.php | 145 ++++++++ tests/Unit/Server/ServerConnectionTest.php | 104 ++++++ tests/Unit/SocketFactoryTest.php | 52 +++ 70 files changed, 4119 insertions(+), 1427 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .php-cs-fixer.dist.php create mode 100644 docs/architecture.md create mode 100644 docs/client/ssl.md create mode 100644 docs/client/tcp.md create mode 100644 docs/client/tls.md create mode 100644 docs/client/udp.md create mode 100644 docs/cookbook/chat-server.md create mode 100644 docs/cookbook/smtp-client.md create mode 100644 docs/getting-started.md create mode 100644 docs/migration-1.x-to-2.x.md create mode 100644 docs/server/ssl.md create mode 100644 docs/server/tcp.md create mode 100644 docs/server/tls.md create mode 100644 docs/server/udp.md create mode 100644 phpstan.neon.dist create mode 100644 phpunit.xml.dist create mode 100644 src/Channel/StreamChannel.php create mode 100644 src/Channel/TcpChannel.php create mode 100644 src/Channel/UdpChannel.php create mode 100644 src/Client/AbstractClient.php create mode 100644 src/Client/AbstractStreamClient.php delete mode 100644 src/Common/BaseClient.php delete mode 100644 src/Common/BaseCommon.php delete mode 100644 src/Common/BaseServer.php delete mode 100644 src/Common/ServerTrait.php delete mode 100644 src/Common/StreamClientTrait.php delete mode 100644 src/Common/StreamServerTrait.php create mode 100644 src/Enum/CryptoMethod.php create mode 100644 src/Enum/Domain.php create mode 100644 src/Enum/Transport.php create mode 100644 src/Exception/SocketExceptionInterface.php create mode 100644 src/Interfaces/ChannelInterface.php create mode 100644 src/Interfaces/SocketConnectionInterface.php delete mode 100644 src/Interfaces/SocketServerClientInterface.php create mode 100644 src/Server/AbstractServer.php create mode 100644 src/Server/AbstractStreamServer.php delete mode 100644 src/Server/ServerClient.php create mode 100644 src/Server/ServerConnection.php create mode 100644 tests/Integration/IntegrationTestCase.php create mode 100644 tests/Integration/TcpEchoTest.php create mode 100644 tests/Integration/TlsEchoTest.php create mode 100644 tests/Integration/UdpEchoTest.php create mode 100644 tests/Unit/Channel/UdpChannelTest.php create mode 100644 tests/Unit/Enum/CryptoMethodTest.php create mode 100644 tests/Unit/Enum/DomainTest.php create mode 100644 tests/Unit/Enum/TransportTest.php create mode 100644 tests/Unit/Exception/HierarchyTest.php create mode 100644 tests/Unit/Server/AbstractServerBroadcastTest.php create mode 100644 tests/Unit/Server/ServerConnectionTest.php create mode 100644 tests/Unit/SocketFactoryTest.php diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..aa5217c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,24 @@ +version: 2 +updates: + - package-ecosystem: composer + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + labels: + - dependencies + commit-message: + prefix: chore + include: scope + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + labels: + - dependencies + - ci + commit-message: + prefix: chore + include: scope diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9fbff05 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,99 @@ +name: CI + +on: + push: + branches: + - main + - 2.x + pull_request: + branches: + - main + - 2.x + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + static-analysis: + name: Static analysis & code style + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + extensions: sockets, openssl + coverage: none + tools: composer:v2 + + - name: Get composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache composer dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: composer-${{ runner.os }}-${{ hashFiles('composer.json') }} + restore-keys: composer-${{ runner.os }}- + + - name: Install dependencies + run: composer install --no-interaction --no-progress --prefer-dist + + - name: PHPStan + run: composer stan -- --no-progress + + - name: PHP-CS-Fixer (dry-run) + run: composer cs-check + + tests: + name: Tests (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.1', '8.2', '8.3'] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: sockets, openssl, pcntl, posix + coverage: pcov + tools: composer:v2 + + - name: Get composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache composer dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: composer-${{ runner.os }}-php${{ matrix.php }}-${{ hashFiles('composer.json') }} + restore-keys: composer-${{ runner.os }}-php${{ matrix.php }}- + + - name: Install dependencies + run: composer install --no-interaction --no-progress --prefer-dist + + - name: Run test suite + run: composer test-coverage + + - name: Upload coverage to Codecov + if: matrix.php == '8.3' + uses: codecov/codecov-action@v4 + with: + files: build/coverage.xml + fail_ci_if_error: false + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index a879886..a772aae 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,9 @@ /.vs/ /.vscode/ /vendor/ -/composer.lock \ No newline at end of file +/composer.lock +/build/ +/coverage/ +/.phpunit.cache/ +/.phpstan-cache/ +/.php-cs-fixer.cache diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..b908d68 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,37 @@ +in([__DIR__ . '/src', __DIR__ . '/tests']) + ->name('*.php') + ->ignoreDotFiles(true) + ->ignoreVCS(true); + +return (new PhpCsFixer\Config()) + ->setRiskyAllowed(true) + ->setRules([ + '@PSR12' => true, + '@PSR12:risky' => true, + '@PHP81Migration' => true, + 'declare_strict_types' => true, + 'native_function_invocation' => [ + 'include' => ['@compiler_optimized'], + 'scope' => 'namespaced', + 'strict' => true, + ], + 'no_unused_imports' => true, + 'ordered_imports' => [ + 'sort_algorithm' => 'alpha', + 'imports_order' => ['class', 'function', 'const'], + ], + 'single_quote' => true, + 'array_syntax' => ['syntax' => 'short'], + 'trailing_comma_in_multiline' => ['elements' => ['arrays', 'arguments', 'parameters']], + 'no_superfluous_phpdoc_tags' => ['allow_mixed' => true], + 'phpdoc_align' => ['align' => 'left'], + 'phpdoc_order' => true, + 'phpdoc_separation' => true, + ]) + ->setFinder($finder) + ->setCacheFile(__DIR__ . '/.php-cs-fixer.cache'); diff --git a/README.md b/README.md index 3ff55e5..33b1479 100644 --- a/README.md +++ b/README.md @@ -1,210 +1,277 @@ -# InitPHP Socket Manager +# initphp/socket -PHP Socket (TCP, TLS, UDP, SSL) Server/Client Library +[![Latest Stable Version](https://poser.pugx.org/initphp/socket/v)](https://packagist.org/packages/initphp/socket) +[![Total Downloads](https://poser.pugx.org/initphp/socket/downloads)](https://packagist.org/packages/initphp/socket) +[![License](https://poser.pugx.org/initphp/socket/license)](https://packagist.org/packages/initphp/socket) +[![PHP Version Require](https://poser.pugx.org/initphp/socket/require/php)](https://packagist.org/packages/initphp/socket) +[![CI](https://github.com/InitPHP/Socket/actions/workflows/ci.yml/badge.svg)](https://github.com/InitPHP/Socket/actions/workflows/ci.yml) -[![Latest Stable Version](http://poser.pugx.org/initphp/socket/v)](https://packagist.org/packages/initphp/socket) [![Total Downloads](http://poser.pugx.org/initphp/socket/downloads)](https://packagist.org/packages/initphp/socket) [![Latest Unstable Version](http://poser.pugx.org/initphp/socket/v/unstable)](https://packagist.org/packages/initphp/socket) [![License](http://poser.pugx.org/initphp/socket/license)](https://packagist.org/packages/initphp/socket) [![PHP Version Require](http://poser.pugx.org/initphp/socket/require/php)](https://packagist.org/packages/initphp/socket) +A lightweight TCP, UDP, TLS and SSL socket toolkit for PHP 8.1+. Both server +and client sides share a clean, typed API built around enums and a small +`Channel` strategy so each transport plugs in without `switch` ladders. + +```php +use InitPHP\Socket\Socket; +use InitPHP\Socket\Enum\Transport; + +$server = Socket::server(Transport::TCP, '127.0.0.1', 8080); +$server->listen(); +$server->live(function ($srv, $conn) { + $message = $conn->read(1024); + $conn->write("echo: {$message}"); +}); +``` ## Requirements -- PHP 7.4 or higher -- PHP Sockets Extension +- PHP **8.1+** +- ext-sockets +- ext-openssl (TLS / SSL) +- ext-pcntl (only for the integration test suite) ## Installation -``` +```bash composer require initphp/socket ``` -## Usage +## Features -**Supported Types :** +- **TCP, UDP, TLS, SSL** — one factory, one interface per side. +- **Non-blocking, select-driven server loop** — `live()` runs forever, or + drive the loop yourself one iteration at a time with `tick()` (great for + embedding into your own event loop or for deterministic tests). +- **First-class enums** — `Transport`, `Domain` and `CryptoMethod` replace + magic strings and integer flags. +- **Strategy-based channels** — `TcpChannel`, `UdpChannel` and + `StreamChannel` isolate the transport-specific I/O. No static state shared + between server instances. +- **Coherent exception hierarchy** — every exception implements + `SocketExceptionInterface`, so a single catch covers the package. +- **Typed everywhere** — PHP 8.1 enums, readonly promoted properties, full + `declare(strict_types=1)` coverage. +- **No silent data loss** — liveness checks never consume data from the + wire. -- TCP -- UDP -- TLS -- SSL +## Quick start -### Factory +### TCP echo server -`\InitPHP\Socket\Socket::class` It allows you to easily create socket server or client. +```php +use InitPHP\Socket\Socket; +use InitPHP\Socket\Enum\Transport; +use InitPHP\Socket\Interfaces\{SocketServerInterface, SocketConnectionInterface}; -#### `Socket::server()` +$server = Socket::server(Transport::TCP, '127.0.0.1', 8080); +$server->listen(); -```php -public static function server(int $handler = Socket::TCP, string $host = '', int $port = 0, null|string|float $argument = null): \InitPHP\Socket\Interfaces\SocketServerInterface +$server->live(function (SocketServerInterface $srv, SocketConnectionInterface $conn) { + $message = $conn->read(1024); + if ($message === null) { + return; + } + if ($message === 'quit') { + $conn->write("bye\n"); + $conn->close(); + return; + } + $conn->write("echo: {$message}"); +}); ``` -- `$handler` : `Socket::SSL`, `Socket::TCP`, `Socket::TLS` or `Socket::UDP` -- `$host` : Identifies the socket host. If not defined or left blank, it will throw an error. -- `$port` : Identifies the socket port. If not defined or left blank, it will throw an error. -- `$argument` : This value is the value that will be sent as 3 parameters to the constructor method of the handler. - - SSL or TLS = (float) Defines the timeout period. - - UDP or TCP = (string) Defines the protocol family to be used by the socket. "v4", "v6" or "unix" - -#### `Socket::client()` +### TCP client -```php -public static function client(int $handler = self::TCP, string $host = '', int $port = 0, null|string|float $argument = null): \InitPHP\Socket\Interfaces\SocketClientInterface -``` - -- `$handler` : `Socket::SSL`, `Socket::TCP`, `Socket::TLS` or `Socket::UDP` -- `$host` : Identifies the socket host. If not defined or left blank, it will throw an error. -- `$port` : Identifies the socket port. If not defined or left blank, it will throw an error. -- `$argument` : This value is the value that will be sent as 3 parameters to the constructor method of the handler. - - SSL or TLS = (float) Defines the timeout period. - - UDP or TCP = (string) Defines the protocol family to be used by the socket. "v4", "v6" or "unix" +```php +use InitPHP\Socket\Socket; +use InitPHP\Socket\Enum\Transport; -### Methods +$client = Socket::client(Transport::TCP, '127.0.0.1', 8080); +$client->connect(); -**`connection()` :** Initiates the socket connection. +$client->write("hello\n"); +echo $client->read(1024); -```php -public function connection(): self; +$client->disconnect(); ``` -**`disconnect()` :** Terminates the connection. +### TLS server (chat-style with named clients) -```php -public function disconnect(): bool; -``` - -**`read()` :** Reads data from socket. +```php +use InitPHP\Socket\Socket; +use InitPHP\Socket\Enum\Transport; +use InitPHP\Socket\Interfaces\{SocketServerInterface, SocketConnectionInterface}; -```php -public function read(int $length = 1024): ?string; -``` +$server = Socket::server(Transport::TLS, '127.0.0.1', 8443, timeout: 5.0) + ->option('local_cert', __DIR__ . '/server.pem') + ->option('allow_self_signed', true); -**`write()` :** Writes data to the socket +$server->listen(); -```php -public function write(string $string): ?int; +$server->live(function (SocketServerInterface $srv, SocketConnectionInterface $conn) { + $input = $conn->read(); + if ($input === null) { + return; + } + if (\preg_match('/^REGISTER\s+([\w-]{3,})$/i', $input, $m) === 1) { + $srv->register($m[1], $conn); + $conn->write("Welcome, {$m[1]}\n"); + return; + } + if (\preg_match('/^SEND\s+@([\w-]+)\s+(.*)$/i', $input, $m) === 1) { + $srv->broadcast($m[2], $m[1]); + return; + } + $srv->broadcast(\trim($input)); +}); ``` -#### Server Methods - -**`live()` :** +### SSL client (talking to Gmail SMTP) -```php -public function live(callable $callback): void; -``` +```php +use InitPHP\Socket\Socket; +use InitPHP\Socket\Enum\Transport; -**`wait()` :** +$client = Socket::client(Transport::SSL, 'smtp.gmail.com', 465, timeout: 10.0) + ->option('verify_peer', false) + ->option('verify_peer_name', false); -```php -public function wait(int $second): void; +$client->connect(); +$client->write("EHLO [127.0.0.1]\r\n"); +echo $client->read(1024); +$client->disconnect(); ``` -**`broadcast()` :** +## API surface + +### Factory — `InitPHP\Socket\Socket` ```php -public function broadcast(string $message, array|string|int|null $clients = null): bool; +Socket::server( + Transport $transport, + string $host, + int $port, + ?Domain $domain = null, // Defaults to Domain::V4 for TCP/UDP. Ignored for TLS/SSL. + ?float $timeout = null, // Connect/handshake timeout for TLS/SSL. +): SocketServerInterface; + +Socket::client( + Transport $transport, + string $host, + int $port, + ?Domain $domain = null, + ?float $timeout = null, +): SocketClientInterface; ``` -#### Special methods for TLS and SSL. - -TLS and SSL work similarly. +### Servers — `InitPHP\Socket\Interfaces\SocketServerInterface` -There are some additional methods you can use from TLS and SSL sockets. +| Method | Purpose | +| --- | --- | +| `listen(): static` | Bind and start listening. Does **not** accept clients. | +| `live(callable $cb, float $idle = 0.05): void` | Run the accept/dispatch loop until `stop()` is called. | +| `tick(callable $cb, float $wait = 0.0): int` | One iteration of the loop. Returns events processed. | +| `stop(): void` | Cooperatively exit the loop started by `live()`. | +| `close(): bool` | Tear everything down (every client + the listening socket). | +| `broadcast(string $msg, int\|string\|array\|null $ids = null): bool` | Send to all clients or a targeted subset. | +| `register(int\|string $id, SocketConnectionInterface $conn): bool` | Attach an addressable id to a connection. | +| `getClients(): array` | Map of `id|key → connection`. | -**`timeout()` :** Defines the timeout period of the current. +`AbstractStreamServer` (TLS / SSL) additionally exposes: ```php -public function timeout(int $second): self; +$server->option(string $key, mixed $value): static // SSL stream context option +$server->timeout(float $seconds): static +$server->blocking(bool $mode = true): static +$server->crypto(?CryptoMethod $method): static ``` -**`blocking()` :** Sets the blocking mode of the current. +### Clients — `InitPHP\Socket\Interfaces\SocketClientInterface` -```php -public function blocking(bool $mode = true): self; -``` +| Method | Purpose | +| --- | --- | +| `connect(): static` | Open the connection. | +| `disconnect(): bool` | Close the connection. Idempotent. | +| `read(int $len = 1024): ?string` | Receive up to `$len` bytes. Returns `null` on no data / failure. | +| `write(string $data): ?int` | Send `$data`. Returns the number of bytes written, or `null` on failure. | + +`AbstractStreamClient` (TLS / SSL) adds `option()`, `timeout()`, `blocking()` +and `crypto()` — same shape as the server side. -**`crypto()` :** Turns encryption on or off on a connected socket. +### Enums ```php -public function crypto(?string $method = null): self; +InitPHP\Socket\Enum\Transport // TCP, UDP, TLS, SSL +InitPHP\Socket\Enum\Domain // V4, V6, UNIX +InitPHP\Socket\Enum\CryptoMethod // SSLv2/3/23, ANY, TLS, TLSv1_0/1_1/1_2 ``` -Possible values for `$method` are; - -- "sslv2" -- "sslv3" -- "sslv23" -- "any" -- "tls" -- "tlsv1.0" -- "tlsv1.1" -- "tlsv1.2" -- NULL +### Exceptions -**`option()` :** Defines connection options for SSL and TLS. see; [https://www.php.net/manual/en/context.ssl.php](https://www.php.net/manual/en/context.ssl.php) +Every package exception implements `SocketExceptionInterface`, so a single +`catch (SocketExceptionInterface $e)` covers them all. -```php -public function option(string $key, mixed $value): self; +``` +SocketExceptionInterface +├── SocketException (extends \RuntimeException) +│ ├── SocketConnectionException +│ └── SocketListenException +└── SocketInvalidArgumentException (extends \InvalidArgumentException) ``` -### Socket Server +## Embedding into your own event loop -_**Example :**_ +If you already run an event loop (ReactPHP, Amp, Swoole-bridge, etc.), do +not call `live()` — invoke `tick()` from your loop and let the host decide +when to yield: ```php -require_once "../vendor/autoload.php"; -use \InitPHP\Socket\Socket; -use \InitPHP\Socket\Interfaces\{SocketServerInterface, SocketServerClientInterface}; - -$server = Socket::server(Socket::TLS, '127.0.0.1', 8080); -$server->connection(); +$server->listen(); -$server->live(function (SocketServerInterface $socket, SocketServerClientInterface $client) { - $read = $client->read(); - if (!$read) { - return; - } - if (in_array($read, ['exit', 'quit'])) { - $client->push("Goodbye!"); - $client->close(); - return; - } else if (preg_match('/^REGISTER\s+([\w]{3,})$/i', $read, $matches)) { - // REGISTER admin - $name = trim(mb_substr($read, 9)); - $socket->clientRegister($name, $client); - } else if (preg_match('/^SEND\s@([\w]+)\s(.*)$/i', $read, $matches)) { - // SEND @admin Hello World - $pushSocketName = $matches[1]; - $message = $matches[2]; - $socket->broadcast($message, [$pushSocketName]) - } else { - $message = trim($read); - !empty($message) && $socket->broadcast($message); +while ($yourEventLoop->running()) { + $events = $server->tick(function ($srv, $conn) { /* ... */ }, waitSeconds: 0.0); + if ($events === 0) { + $yourEventLoop->yield(); } -}); +} ``` -### Socket Client +## Documentation -_**Example :**_ +In-depth guides live under [`docs/`](./docs): -```php -require_once "../vendor/autoload.php"; -use \InitPHP\Socket\Socket; +- [Getting started](./docs/getting-started.md) +- [Architecture](./docs/architecture.md) +- Servers: [TCP](./docs/server/tcp.md) · [UDP](./docs/server/udp.md) · [TLS](./docs/server/tls.md) · [SSL](./docs/server/ssl.md) +- Clients: [TCP](./docs/client/tcp.md) · [UDP](./docs/client/udp.md) · [TLS](./docs/client/tls.md) · [SSL](./docs/client/ssl.md) +- Cookbook: [Chat server](./docs/cookbook/chat-server.md) · [SMTP client](./docs/cookbook/smtp-client.md) +- [Migrating from 1.x](./docs/migration-1.x-to-2.x.md) -$client = Socket::client(Socket::SSL, 'smtp.gmail.com', 465); +## Development -$client->option('verify_peer', false) - ->option('verify_peer_name', false); +```bash +composer install +composer test # PHPUnit (unit + integration) +composer stan # PHPStan level 8 +composer cs-check # PHP-CS-Fixer dry-run +composer cs-fix # Apply style fixes +composer qa # All of the above +``` -$client->connection(); +CI runs the full QA pipeline on PHP 8.1, 8.2 and 8.3. -$client->write('EHLO [127.0.0.1]'); +## Contributing -echo $client->read(); -``` +Issues, ideas and pull requests are welcome. Please read the +[org-wide contributing guide](https://github.com/InitPHP/.github/blob/main/CONTRIBUTING.md) +before opening a PR. -_In the above example, a simple smtp connection to gmail is made._ +Security issues should be reported privately — see +[SECURITY.md](https://github.com/InitPHP/.github/blob/main/SECURITY.md). ## Credits -- [Muhammet ŞAFAK](https://www.muhammetsafak.com.tr) <> +- [Muhammet ŞAFAK](https://www.muhammetsafak.com.tr) — `` ## License -Copyright © 2022 [MIT License](./LICENSE) +Released under the [MIT License](./LICENSE). diff --git a/composer.json b/composer.json index 7addaf5..6f00ef9 100644 --- a/composer.json +++ b/composer.json @@ -1,13 +1,20 @@ { "name": "initphp/socket", - "description": "Socket Server-Client Library", + "description": "Lightweight TCP, UDP, TLS and SSL socket server/client toolkit for PHP 8.1+.", "type": "library", "license": "MIT", - "autoload": { - "psr-4": { - "InitPHP\\Socket\\": "src/" - } - }, + "keywords": [ + "socket", + "tcp", + "udp", + "tls", + "ssl", + "stream", + "server", + "client", + "networking", + "initphp" + ], "authors": [ { "name": "Muhammet ŞAFAK", @@ -16,9 +23,53 @@ "homepage": "https://www.muhammetsafak.com.tr" } ], - "minimum-stability": "stable", + "support": { + "issues": "https://github.com/InitPHP/Socket/issues", + "source": "https://github.com/InitPHP/Socket", + "docs": "https://github.com/InitPHP/Socket/tree/main/docs" + }, "require": { - "php": ">=7.4", - "ext-sockets": "*" + "php": "^8.1", + "ext-sockets": "*", + "ext-openssl": "*" + }, + "require-dev": { + "phpunit/phpunit": "^10.5", + "phpstan/phpstan": "^1.11", + "friendsofphp/php-cs-fixer": "^3.59" + }, + "autoload": { + "psr-4": { + "InitPHP\\Socket\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "InitPHP\\Socket\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "phpunit", + "test-coverage": "phpunit --coverage-clover=build/coverage.xml", + "stan": "phpstan analyse", + "cs-check": "php-cs-fixer fix --dry-run --diff", + "cs-fix": "php-cs-fixer fix", + "qa": [ + "@cs-check", + "@stan", + "@test" + ] + }, + "scripts-descriptions": { + "test": "Run the PHPUnit test suite.", + "test-coverage": "Run the PHPUnit test suite and emit a Clover coverage report.", + "stan": "Run PHPStan static analysis on src/.", + "cs-check": "Verify code style with PHP-CS-Fixer (no changes written).", + "cs-fix": "Auto-fix code style with PHP-CS-Fixer.", + "qa": "Run the full QA pipeline (cs-check, stan, test)." + }, + "minimum-stability": "stable", + "config": { + "sort-packages": true } } diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..a6ea82a --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,133 @@ +# Architecture + +The 2.x line was designed around three goals: kill the shared-state bugs +of the 1.x release, isolate transport-specific I/O behind a strategy +interface, and stay friendly to host event loops. This page is the map +of how the pieces fit. + +## Layers at a glance + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Socket factory │ +│ (Socket::server / Socket::client) │ +└─────────────────────────────────────────────────────────────┘ + │ + ┌─────────────────┴─────────────────┐ + ▼ ▼ +┌────────────────────┐ ┌────────────────────┐ +│ SocketServerInter │ │ SocketClientInter │ +│ face │ │ face │ +│ ─ AbstractServer │ │ ─ AbstractClient │ +│ ├─ TCP / UDP │ │ ├─ TCP / UDP │ +│ └─ AbstractStrea │ │ └─ AbstractStrea │ +│ mServer │ │ mClient │ +│ ├─ TLS │ │ ├─ TLS │ +│ └─ SSL │ │ └─ SSL │ +└────────────────────┘ └────────────────────┘ + │ │ + ▼ │ +┌────────────────────┐ │ +│ ServerConnection │ uses │ +│ (per accepted │◀─────────────────────┘ (clients hold the +│ client) │ resource directly) +└────────────────────┘ + │ + ▼ +┌────────────────────────────────────────┐ +│ ChannelInterface │ +│ ├─ TcpChannel (ext-sockets) │ +│ ├─ UdpChannel (ext-sockets, buffer) │ +│ └─ StreamChannel (ext-openssl streams) │ +└────────────────────────────────────────┘ +``` + +## Why `Channel`? + +A 1.x `ServerClient` carried a `switch ($type)` ladder in `push()`, +`read()`, `close()` and `isDisconnected()`. Adding a new transport meant +editing the class. The 2.x split moves transport-specific I/O into +dedicated `Channel` implementations: + +- **`TcpChannel`** uses `socket_recv` / `socket_write` and detects peer + close non-destructively with `MSG_PEEK | MSG_DONTWAIT`. +- **`StreamChannel`** uses `fread` / `fwrite` and asks `feof()` whether + the peer is gone — never consuming data to find out. +- **`UdpChannel`** binds an identity (`peerHost:peerPort`) to the + server's listening socket. The server routes inbound datagrams into + the channel's local buffer via `feed()`; reads drain the buffer. + Writes use `socket_sendto` directly. + +`ServerConnection` just holds an id and forwards calls to its channel. +That keeps the per-connection object honest about its scope — identity +plus delegation — and makes broadcasting / id mapping trivial to test +with a fake channel. + +## The server loop + +The accept/dispatch flow is broken into two layers: + +1. **`live(callable, float)`** — the long-running loop. It sets + `$running = true` and repeatedly calls `tick()` until `stop()` is + invoked. +2. **`tick(callable, float)`** — a single iteration. It runs the + transport-specific `select()`, accepts new connections, services + readable existing ones, and returns the number of events handled. + +The split is deliberate. `tick()` is the integration seam: drop the +package into a host event loop and call `tick(waitSeconds: 0)` whenever +your scheduler picks our server. Tests use the same seam to drive the +server deterministically without forking processes. + +## Why select-driven? + +The 1.x `live()` called blocking `socket_accept()` on every iteration, +so existing clients were ignored until a new one connected. The 2.x +loop builds a read set of `[listenSocket, ...activeClientSockets]`, +hands it to `socket_select` / `stream_select` with the caller-supplied +timeout, then services exactly the resources the kernel said are ready. + +Non-blocking accept is set after a `select()` reports a pending client, +not as a permanent property of the listening socket — `stream_socket_server` +behaves differently than `socket_create` here, and the stream-server +implementation keeps the listen socket blocking so `stream_socket_accept` +has room to complete the TLS handshake within its timeout. + +## Liveness, no data loss + +`isAlive()` never touches the application payload: + +- **TCP** — `socket_recv($sock, $tmp, 1, MSG_PEEK | MSG_DONTWAIT)`. A + zero return value means the peer closed; `EAGAIN`/`EWOULDBLOCK` means + alive-but-quiet. +- **Stream (TLS / SSL)** — `feof()` after confirming the resource is + still valid. +- **UDP** — an in-process `alive` flag; UDP has no connection state, + so dead peers are detected by application-level TTL. + +## Exceptions + +Every exception thrown by the package implements +`SocketExceptionInterface`, so callers can do: + +```php +try { + $server->listen(); +} catch (SocketExceptionInterface $e) { + // bind failed, listen failed, invalid argument — all caught here +} +``` + +The hierarchy: + +``` +SocketExceptionInterface +├─ SocketException (RuntimeException) +│ ├─ SocketConnectionException +│ └─ SocketListenException +└─ SocketInvalidArgumentException (InvalidArgumentException) +``` + +The split keeps the "your input is wrong" path on +`InvalidArgumentException` semantics while still being catchable +together with runtime failures via the marker interface. diff --git a/docs/client/ssl.md b/docs/client/ssl.md new file mode 100644 index 0000000..96bab8d --- /dev/null +++ b/docs/client/ssl.md @@ -0,0 +1,37 @@ +# SSL client + +`InitPHP\Socket\Client\SSL` opens an `ssl://` stream. Functionally +identical to the [TLS client](./tls.md); the URL scheme just affects +PHP's default cipher negotiation. + +```php +use InitPHP\Socket\Socket; +use InitPHP\Socket\Enum\Transport; + +$client = Socket::client(Transport::SSL, 'smtp.gmail.com', 465, timeout: 10.0) + ->option('verify_peer', false) + ->option('verify_peer_name', false); + +$client->connect(); +$client->write("EHLO [127.0.0.1]\r\n"); +echo $client->read(1024); +$client->disconnect(); +``` + +Refer to the [TLS client guide](./tls.md) for the full option list and +error-handling notes — every helper translates directly. + +## When to pick `SSL` vs `TLS` + +Stick with `Transport::TLS` unless a specific server only accepts +`ssl://`. Modern peers negotiate TLS 1.2 / 1.3 either way; the +`ssl://` scheme is mostly useful for compatibility with legacy +endpoints. + +To force a specific protocol version, pin it via `crypto()`: + +```php +use InitPHP\Socket\Enum\CryptoMethod; + +$client->crypto(CryptoMethod::TLSv1_2); +``` diff --git a/docs/client/tcp.md b/docs/client/tcp.md new file mode 100644 index 0000000..e1a3f57 --- /dev/null +++ b/docs/client/tcp.md @@ -0,0 +1,56 @@ +# TCP client + +A thin wrapper over `socket_create` + `socket_connect` for stream +sockets. + +## Quick reference + +```php +use InitPHP\Socket\Socket; +use InitPHP\Socket\Enum\{Transport, Domain}; + +$client = Socket::client(Transport::TCP, '127.0.0.1', 8080, Domain::V4); +$client->connect(); + +$client->write("PING\n"); +echo $client->read(1024); + +$client->disconnect(); +``` + +## Read modes + +`read(int $length = 1024, int $type = PHP_BINARY_READ)` accepts either +of the two PHP reading modes: + +| Constant | Behaviour | +| --- | --- | +| `PHP_BINARY_READ` (default) | Read up to `$length` raw bytes. | +| `PHP_NORMAL_READ` | Read until `\n` or `\r` is seen (line-oriented). | + +## Error handling + +`connect()` throws `SocketConnectionException` if the remote endpoint +refuses or the OS can't open the socket. `read()` / `write()` return +`null` instead of raising — wrap with your own retry logic if you +need it. + +```php +try { + $client->connect(); +} catch (\InitPHP\Socket\Exception\SocketConnectionException $e) { + // retry, fall back, log, ... +} +``` + +## UNIX domain sockets + +Pass `Domain::UNIX` and the filesystem path as the `host`: + +```php +$client = Socket::client(Transport::TCP, '/tmp/initphp.sock', 1, Domain::UNIX); +$client->connect(); +``` + +(The `port` argument is ignored for UDS, but must satisfy the `>0` +check — pass any placeholder.) diff --git a/docs/client/tls.md b/docs/client/tls.md new file mode 100644 index 0000000..472aff6 --- /dev/null +++ b/docs/client/tls.md @@ -0,0 +1,66 @@ +# TLS client + +A `tls://` stream client. Use this for anything talking modern TLS: +HTTPS, secure SMTP, AMQP, etc. + +## Quick reference + +```php +use InitPHP\Socket\Socket; +use InitPHP\Socket\Enum\Transport; + +$client = Socket::client(Transport::TLS, 'example.com', 443, timeout: 5.0); +$client->connect(); + +$client->write("GET / HTTP/1.0\r\nHost: example.com\r\n\r\n"); +while (($chunk = $client->read(4096)) !== null) { + echo $chunk; +} +$client->disconnect(); +``` + +## SSL context options + +`option(string $key, mixed $value)` is a passthrough for PHP's +[SSL context options](https://www.php.net/manual/en/context.ssl.php): + +```php +$client = Socket::client(Transport::TLS, 'example.com', 443) + ->option('verify_peer', true) + ->option('verify_peer_name', true) + ->option('cafile', '/etc/ssl/certs/ca-certificates.crt') + ->option('SNI_enabled', true); +``` + +For development against a self-signed server: + +```php +$client = Socket::client(Transport::TLS, '127.0.0.1', 8443) + ->option('verify_peer', false) + ->option('verify_peer_name', false) + ->option('allow_self_signed', true); +``` + +> **Never ship `verify_peer => false`.** Always configure trust correctly +> in production. The development flags exist for testing only. + +## Fluent helpers + +| Method | Purpose | +| --- | --- | +| `option(string $key, mixed $value)` | Set any SSL context option. | +| `timeout(float $seconds)` | Connect / handshake timeout. Applied to live streams too. | +| `blocking(bool $mode = true)` | Default `true` — set to `false` for non-blocking I/O. | +| `crypto(?CryptoMethod $method)` | Toggle / pin a specific cipher family after `connect()`. | + +## Error handling + +`connect()` throws `SocketConnectionException` when: + +- the TCP connect fails, +- the TLS handshake fails, +- or any underlying stream error is reported. + +Inspect the message for the `(errno): description` PHP returned. For +"peer's CN does not match" or "self signed certificate" errors, +re-check the `verify_*` / `cafile` options above. diff --git a/docs/client/udp.md b/docs/client/udp.md new file mode 100644 index 0000000..f6ea0f3 --- /dev/null +++ b/docs/client/udp.md @@ -0,0 +1,40 @@ +# UDP client + +A datagram client. After `connect()`, the kernel locks the peer +address; `read()` / `write()` then behave like a stream socket and +talk only to that peer. + +## Quick reference + +```php +use InitPHP\Socket\Socket; +use InitPHP\Socket\Enum\Transport; + +$client = Socket::client(Transport::UDP, '127.0.0.1', 9000); +$client->connect(); + +$client->write('ping'); +echo $client->read(65535); + +$client->disconnect(); +``` + +## Flags + +Both `read()` and `write()` accept an optional `int $flags` argument: + +| Flag set | Sensible places | +| --- | --- | +| `MSG_OOB`, `MSG_PEEK`, `MSG_WAITALL`, `MSG_DONTWAIT` | `read()` | +| `MSG_OOB`, `MSG_EOR`, `MSG_EOF`, `MSG_DONTROUTE` | `write()` | + +Leave them at `0` unless you are sure you need them. + +## Caveats + +- **No retransmits.** A successful `write()` only proves the OS + accepted the packet for sending. Datagram loss is silent. +- **No connection state.** `disconnect()` only closes the local + socket; the peer has no way to learn the client is gone. +- **Packet size.** UDP over IPv4 maxes out at 65 507 bytes of payload. + Stay under MTU (≈1472 bytes on Ethernet) to avoid fragmentation. diff --git a/docs/cookbook/chat-server.md b/docs/cookbook/chat-server.md new file mode 100644 index 0000000..3a8970d --- /dev/null +++ b/docs/cookbook/chat-server.md @@ -0,0 +1,77 @@ +# Cookbook — chat server + +A small TCP chat server that supports three commands: + +- `REGISTER ` — claim a name for the rest of the session. +- `SEND @ ` — direct message to a registered peer. +- anything else — broadcast to every connected client. + +```php +listen(); + +echo "Chat server listening on 127.0.0.1:8080\n"; + +$server->live(function (SocketServerInterface $srv, SocketConnectionInterface $conn) { + $input = $conn->read(4096); + if ($input === null) { + return; + } + $input = \trim($input); + if ($input === '') { + return; + } + + if (\in_array($input, ['quit', 'exit'], true)) { + $conn->write("Goodbye!\n"); + $conn->close(); + return; + } + + if (\preg_match('/^REGISTER\s+([\w-]{3,})$/i', $input, $m) === 1) { + $srv->register($m[1], $conn); + $conn->write("Registered as {$m[1]}\n"); + return; + } + + if (\preg_match('/^SEND\s+@([\w-]+)\s+(.+)$/i', $input, $m) === 1) { + $srv->broadcast("[{$conn->getId()} → {$m[1]}] {$m[2]}\n", $m[1]); + return; + } + + $srv->broadcast("[{$conn->getId()}] {$input}\n"); +}); +``` + +Try it from two terminals: + +```bash +# Terminal A +$ nc 127.0.0.1 8080 +REGISTER alice +Registered as alice + +# Terminal B +$ nc 127.0.0.1 8080 +REGISTER bob +Registered as bob +SEND @alice hey there +``` + +## Why it works + +- `register(id, conn)` records the mapping so `broadcast(message, id)` + can find the right channel. +- Until a client `REGISTER`s, `$conn->getId()` is `null` — the broadcast + line shows `null` which is fine for a demo. In a real product you + would refuse `SEND` until the sender is named. +- `$conn->close()` immediately tears the channel down; the next + `tick()` finds the socket dead and evicts it from `getClients()`. diff --git a/docs/cookbook/smtp-client.md b/docs/cookbook/smtp-client.md new file mode 100644 index 0000000..cafff32 --- /dev/null +++ b/docs/cookbook/smtp-client.md @@ -0,0 +1,71 @@ +# Cookbook — SMTP client (raw) + +A minimal raw SMTP client that opens a TLS connection to Gmail and +performs the initial `EHLO` exchange. Useful as a smoke test that the +TLS client is configured correctly; **do not** ship this as a real +mailer — use `initphp/mailer` (or any battle-tested library) for that. + +```php +option('verify_peer', false) + ->option('verify_peer_name', false); + +$client->connect(); + +echo $client->read(1024); // 220 banner + +$client->write("EHLO localhost\r\n"); +echo $client->read(1024); // 250 capabilities + +$client->write("QUIT\r\n"); +echo $client->read(1024); + +$client->disconnect(); +``` + +Expected output (truncated): + +``` +220 smtp.gmail.com ESMTP … +250-smtp.gmail.com at your service, … +250-SIZE … +250-STARTTLS +250 SMTPUTF8 +221 2.0.0 closing connection +``` + +## Why these options + +- `Transport::SSL` because Gmail's `465` port serves implicit TLS + (the connection is encrypted from the first byte). For port `587` + you'd open a `TCP` connection first and upgrade it with `STARTTLS`. +- `verify_peer` is disabled here only for the demo. In a real client + you must verify the peer certificate (`option('cafile', ...)`). + +## Reading line-by-line + +SMTP is line-oriented. The simple `read(1024)` above takes whatever +chunk the OS hands over, which usually includes the full response. +For a robust client you would loop until the response code's final +line (`250 …`, not `250-…`): + +```php +function smtpRead($client): string +{ + $out = ''; + while (($chunk = $client->read(1024)) !== null) { + $out .= $chunk; + if (\preg_match('/^\d{3} [^\r\n]*\r?\n$/m', $chunk)) { + break; + } + } + return $out; +} +``` diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..9a1a243 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,88 @@ +# Getting started + +`initphp/socket` is a thin layer on top of the PHP `sockets` extension and +the stream socket family. It gives you a transport-agnostic factory, a +non-blocking server loop and a small `Channel` abstraction so the same +mental model fits TCP, UDP, TLS and SSL. + +## Install + +```bash +composer require initphp/socket +``` + +You will also need `ext-sockets` (always) and `ext-openssl` (for TLS / SSL). + +## The five-minute tour + +```php +use InitPHP\Socket\Socket; +use InitPHP\Socket\Enum\Transport; +use InitPHP\Socket\Interfaces\{SocketServerInterface, SocketConnectionInterface}; + +// 1. Build a server. Nothing has happened on the network yet. +$server = Socket::server(Transport::TCP, '127.0.0.1', 9000); + +// 2. Bind and listen. +$server->listen(); + +// 3. Run the accept/dispatch loop. The callback is invoked whenever a +// connection has new inbound data. +$server->live(function (SocketServerInterface $srv, SocketConnectionInterface $conn) { + $payload = $conn->read(1024); + if ($payload === null) { + return; + } + $conn->write("got {$payload}"); +}); +``` + +The same pattern works for every transport — only the `Transport` case +changes: + +```php +Socket::server(Transport::TCP, '127.0.0.1', 9000); +Socket::server(Transport::UDP, '127.0.0.1', 9001); +Socket::server(Transport::TLS, '127.0.0.1', 9443, timeout: 5.0) + ->option('local_cert', __DIR__ . '/server.pem'); +Socket::server(Transport::SSL, '127.0.0.1', 9444, timeout: 5.0) + ->option('local_cert', __DIR__ . '/server.pem'); +``` + +## Clients mirror servers + +```php +$client = Socket::client(Transport::TCP, '127.0.0.1', 9000); +$client->connect(); +$client->write("hello\n"); +echo $client->read(1024); +$client->disconnect(); +``` + +## Lifecycle + +A server moves through three states: + +``` +constructed → listening → running → closed + listen() live() close() +``` + +A client is a two-step affair: + +``` +constructed → connected → closed + connect() disconnect() +``` + +Both servers and clients can be re-built; you cannot reuse a closed +instance. + +## Where to go next + +- [Architecture overview](./architecture.md) — how the pieces fit together. +- [Server guides](./server/tcp.md) — transport-by-transport details. +- [Client guides](./client/tcp.md) — same on the connect side. +- [Cookbook](./cookbook/chat-server.md) — runnable examples. +- [Migrating from 1.x](./migration-1.x-to-2.x.md) — if you're coming + from the previous major. diff --git a/docs/migration-1.x-to-2.x.md b/docs/migration-1.x-to-2.x.md new file mode 100644 index 0000000..d89a2a2 --- /dev/null +++ b/docs/migration-1.x-to-2.x.md @@ -0,0 +1,135 @@ +# Migrating from 1.x to 2.x + +2.x is a clean break. The 1.x server loop had race conditions, lost +inbound data through its liveness check, and stored transport state on +a `static` property that leaked between instances. Fixing those needed +API changes, so 2.x ships with a new shape rather than a back-compat +shim. + +This guide walks the renames and behaviour changes you'll hit when +upgrading. The list is short — the public surface stays small. + +## Requirements + +| Aspect | 1.x | 2.x | +| --- | --- | --- | +| PHP | `>=7.4` | `^8.1` | +| Extensions | `ext-sockets` | `ext-sockets`, `ext-openssl` | +| Tested PHP versions | n/a (no CI) | 8.1, 8.2, 8.3 | + +## Factory + +```diff +- use InitPHP\Socket\Socket; ++ use InitPHP\Socket\Socket; ++ use InitPHP\Socket\Enum\Transport; + +- $server = Socket::server(Socket::TCP, '127.0.0.1', 8080); ++ $server = Socket::server(Transport::TCP, '127.0.0.1', 8080); +``` + +The integer constants (`Socket::TCP`, `Socket::UDP`, `Socket::TLS`, +`Socket::SSL`) are gone. The `Transport` enum takes their place. + +The `$argument` mystery parameter (whose meaning depended on transport) +is now two explicit named parameters: + +```diff +- Socket::server(Socket::TCP, '127.0.0.1', 8080, 'v4'); ++ Socket::server(Transport::TCP, '127.0.0.1', 8080, Domain::V4); + +- Socket::server(Socket::TLS, '127.0.0.1', 8443, 5.0); ++ Socket::server(Transport::TLS, '127.0.0.1', 8443, timeout: 5.0); +``` + +## Server method renames + +| 1.x | 2.x | Notes | +| --- | --- | --- | +| `connection()` | `listen()` | More accurate — it only binds + listens. Accept now happens inside `live()` / `tick()`. | +| `disconnect()` | `close()` | Tears down every client and the listening socket. | +| `live(callable $cb, int $usleep = 100000)` | `live(callable $cb, float $idleSeconds = 0.05)` | Same idea, now expressed in seconds. | +| `wait(int\|float $seconds)` | `wait(float $seconds)` | Sub-second precision via a single float. | +| `clientRegister($id, $conn)` | `register($id, $conn)` | Now part of the interface. | +| `broadcast($message, $clients = null)` | `broadcast(string $message, int\|string\|array\|null $ids = null)` | Always returns `bool`. Per-id targeting unchanged. | + +New on `SocketServerInterface`: + +- `tick(callable $cb, float $waitSeconds = 0.0): int` — single-iteration + accept/dispatch step. Use this to embed the server in your own + event loop or to drive it deterministically in tests. +- `stop(): void` — cooperatively exit the `live()` loop. +- `isRunning(): bool` — exposes the loop flag. + +## ServerClient → ServerConnection + +The per-accepted-connection class has been renamed and rebuilt: + +| 1.x: `Server\ServerClient` | 2.x: `Server\ServerConnection` | +| --- | --- | +| `push(string $msg)` | `write(string $data): ?int` | +| `read(int $len, ?int $type = null)` | `read(int $len = 1024): ?string` | +| `close(): bool` | `close(): bool` | +| `isDisconnected(): bool` (consumed data!) | `isAlive(): bool` (non-destructive) | +| `getSocket()` returns mixed | `getSocket(): mixed`, plus `getChannel(): ChannelInterface` | +| `__setSocket()` magic | gone — channels are constructed normally | +| `static $credentials` shared state | gone — every connection owns its own `Channel` | + +The most important behavioural change: **`isAlive()` does not read +data off the wire.** If you were depending on `isDisconnected()` to +both check liveness and consume the next line, you need to call +`read()` explicitly. + +## Client method renames + +| 1.x | 2.x | +| --- | --- | +| `connection()` | `connect()` | +| `disconnect()` | `disconnect()` (unchanged) | +| `read()` / `write()` | `read()` / `write()` (unchanged shape; return `null` instead of `false`) | + +## Exceptions + +All exceptions now implement a common `SocketExceptionInterface`. The +behavioural changes: + +- `SocketException` now extends `\RuntimeException` (was `\Exception`). +- `SocketConnectionException` and `SocketListenException` extend + `SocketException` (were `\Exception`). +- A single catch covers the package: + +```diff +- try { /* ... */ } catch (\InitPHP\Socket\Exception\SocketException $e) { /* ... */ } ++ try { /* ... */ } catch (\InitPHP\Socket\Exception\SocketExceptionInterface $e) { /* ... */ } +``` + +## Removed traits and base classes + +The `Common/` namespace is gone. If you were extending or composing +`BaseClient`, `BaseServer`, `BaseCommon`, `ServerTrait`, +`StreamClientTrait` or `StreamServerTrait`, switch to the new +abstracts: + +| 1.x | 2.x | +| --- | --- | +| `Common\BaseClient` | `Client\AbstractClient` | +| `Common\BaseServer` | `Server\AbstractServer` | +| `Common\StreamClientTrait` | `Client\AbstractStreamClient` | +| `Common\StreamServerTrait` | `Server\AbstractStreamServer` | +| `Common\BaseCommon` / `Common\ServerTrait` | merged into the abstracts | + +## Quick migration checklist + +1. Bump `php` to `^8.1` in your project's `composer.json`. +2. Replace every `Socket::TCP` / `Socket::UDP` / `Socket::TLS` / + `Socket::SSL` reference with the `Transport` enum case. +3. Replace `connection()` → `listen()` / `connect()`. +4. Replace `disconnect()` → `close()` on servers (clients keep it). +5. Rename `ServerClient` references to `ServerConnection` and + `push()` to `write()`. +6. Audit every call to the old `isDisconnected()` — replace with + `isAlive()` and read data explicitly. +7. If you have your own subclasses, point them at the new abstract + parents under `Server/` / `Client/`. +8. Catch `SocketExceptionInterface` instead of (or in addition to) + `SocketException`. diff --git a/docs/server/ssl.md b/docs/server/ssl.md new file mode 100644 index 0000000..227b8f2 --- /dev/null +++ b/docs/server/ssl.md @@ -0,0 +1,35 @@ +# SSL server + +`InitPHP\Socket\Server\SSL` is the `ssl://` scheme counterpart of +[TLS](./tls.md). The class is functionally identical — the only +difference is the URL scheme passed to `stream_socket_server`, which +affects the default ciphers PHP selects. + +```php +use InitPHP\Socket\Socket; +use InitPHP\Socket\Enum\Transport; + +$server = Socket::server(Transport::SSL, '127.0.0.1', 8443, timeout: 5.0) + ->option('local_cert', __DIR__ . '/server.pem'); + +$server->listen(); +``` + +Refer to the [TLS server guide](./tls.md) for the full option list, +handshake notes and certificate setup; everything translates directly. + +## When to pick `SSL` vs `TLS` + +Prefer `Transport::TLS` unless you specifically need the legacy SSLv23 +/ SSLv2 / SSLv3 fallback selection. Modern peers negotiate TLS 1.2 / +1.3 either way; the `ssl://` scheme exists for compatibility with old +PHP code and rarely gives you anything new. + +If you need a specific protocol version, set it explicitly with +`crypto()`: + +```php +use InitPHP\Socket\Enum\CryptoMethod; + +$server->crypto(CryptoMethod::TLSv1_2); +``` diff --git a/docs/server/tcp.md b/docs/server/tcp.md new file mode 100644 index 0000000..34bb16b --- /dev/null +++ b/docs/server/tcp.md @@ -0,0 +1,112 @@ +# TCP server + +Backed by the `sockets` extension (`socket_create` / `socket_listen` / +`socket_accept`). Suitable for any stream-oriented binary or text +protocol. + +## Quick reference + +```php +use InitPHP\Socket\Socket; +use InitPHP\Socket\Enum\{Transport, Domain}; + +$server = Socket::server( + Transport::TCP, + '127.0.0.1', + 8080, + domain: Domain::V4, // V4 (default), V6 or UNIX +); + +$server->listen(); +$server->live(function ($srv, $conn) { + $payload = $conn->read(1024); + if ($payload !== null) { + $conn->write("ack:{$payload}"); + } +}); +``` + +## Options + +| Method | Default | Notes | +| --- | --- | --- | +| `backlog(int $n)` | `8` | OS listen backlog for unaccepted connections. | + +## Domain selection + +| `Domain` case | Address family | +| --- | --- | +| `V4` | `AF_INET` (default) | +| `V6` | `AF_INET6` | +| `UNIX` | `AF_UNIX` (UDS path goes in the `host` argument) | + +UDS example: + +```php +$server = Socket::server(Transport::TCP, '/tmp/initphp.sock', 0, Domain::UNIX); +``` + +> **Note:** for `Domain::UNIX`, the `port` argument is ignored by the +> kernel but still has to satisfy the constructor's `>0` check; pass +> any non-zero placeholder (or use a real port if you ever expose the +> service via TCP too). + +## Lifecycle in code + +```php +$server = Socket::server(Transport::TCP, '127.0.0.1', 8080); + +// throws SocketListenException if bind/listen fails +$server->listen(); + +try { + $server->live(function ($srv, $conn) { + // ... + }); +} finally { + $server->close(); // tears down every client + the listen socket +} +``` + +## Targeted broadcasting + +```php +$server->live(function ($srv, $conn) { + $payload = $conn->read(); + if ($payload === null) return; + + if (\preg_match('/^REGISTER\s+(.+)$/', $payload, $m)) { + $srv->register($m[1], $conn); + return; + } + if (\preg_match('/^DM\s+(\S+)\s+(.+)$/', $payload, $m)) { + $srv->broadcast($m[2], $m[1]); // single id + return; + } + $srv->broadcast($payload); // every client +}); +``` + +`broadcast()` accepts: + +- `null` — every alive client +- `int|string` — the connection previously registered under that id +- `int[]|string[]` — multiple ids at once + +## Driving the loop yourself + +`live()` is just `while (running) tick()`. If you have your own loop, +use `tick()` directly: + +```php +$server->listen(); +while ($app->isRunning()) { + $events = $server->tick(function ($srv, $conn) { + /* ... */ + }, waitSeconds: 0.0); + + if ($events === 0) { + $app->yieldOnce(); + } +} +``` diff --git a/docs/server/tls.md b/docs/server/tls.md new file mode 100644 index 0000000..13f7823 --- /dev/null +++ b/docs/server/tls.md @@ -0,0 +1,78 @@ +# TLS server + +TLS servers wrap `stream_socket_server` with an `ssl://` context (`tls` +scheme). Both the listen and the per-client handshake go through PHP +stream encryption. + +## Quick reference + +```php +use InitPHP\Socket\Socket; +use InitPHP\Socket\Enum\Transport; + +$server = Socket::server(Transport::TLS, '127.0.0.1', 8443, timeout: 5.0) + ->option('local_cert', __DIR__ . '/server.pem') // cert + private key + ->option('verify_peer', false) + ->option('allow_self_signed', true); + +$server->listen(); +$server->live(function ($srv, $conn) { + $payload = $conn->read(); + if ($payload !== null) { + $conn->write("secure: {$payload}"); + } +}); +``` + +## Required SSL context options + +At minimum the server needs `local_cert` (a PEM bundle of the +certificate and its private key). Every option you can pass to PHP's +SSL context is also available here — see the +[PHP manual](https://www.php.net/manual/en/context.ssl.php). + +Common keys: + +| Option | Why you might set it | +| --- | --- | +| `local_cert` | Path to a PEM file with the server certificate (and optionally the private key). | +| `local_pk` | Path to the private key if it lives in a separate file. | +| `passphrase` | Passphrase protecting the private key. | +| `cafile` / `capath` | Trusted CA bundle if you ask clients to authenticate. | +| `verify_peer` | Default `true` — drop the connection if the peer cert can't be verified. | +| `verify_peer_name` | Default `true` — match the certificate CN/SAN against the peer name. | +| `allow_self_signed` | Useful in dev; **do not** ship it. | + +## Fluent helpers + +| Method | Purpose | +| --- | --- | +| `option(string $key, mixed $value)` | Set any SSL context option. | +| `timeout(float $seconds)` | Default socket / handshake timeout. | +| `blocking(bool $mode = true)` | Whether accepted client streams stay blocking. Default `false`. | +| `crypto(?CryptoMethod $method)` | Pin a specific cipher family (`CryptoMethod::TLSv1_2`, …) or `null` to defer to the URL scheme. | + +## Generating a self-signed certificate for local testing + +```bash +openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem \ + -days 365 -subj '/CN=localhost' +cat cert.pem key.pem > server.pem +``` + +Point `option('local_cert', ...)` at the resulting `server.pem`. + +## Caveats + +- **Handshake happens during accept.** `live()` / `tick()` may briefly + block during the TLS exchange of a new connection. For loopback + this is in the low milliseconds; for high-fan-in remote workloads, + consider terminating TLS in a reverse proxy. +- **The listening stream stays in blocking mode** so the handshake has + room to complete inside `stream_socket_accept`'s timeout. `select()` + still drives readiness — the loop will not idle on a blocking + accept call. +- **Verify the peer in production.** The defaults assume you mean it + when you say "verify_peer=false". For real deployments, point + `cafile` at the trust anchor and leave `verify_peer` / `verify_peer_name` + on. diff --git a/docs/server/udp.md b/docs/server/udp.md new file mode 100644 index 0000000..7b3526a --- /dev/null +++ b/docs/server/udp.md @@ -0,0 +1,57 @@ +# UDP server + +UDP is connectionless: a single listening socket fields datagrams from +every peer. `initphp/socket` makes the abstraction look more +connection-shaped without lying about it. + +## What "connection" means for UDP here + +Each unique `peerHost:peerPort` seen on the wire gets its own +`UdpChannel` and `ServerConnection`. The server demultiplexes inbound +datagrams into the right channel via the channel's internal buffer +(`feed()`), so your callback can call `$conn->read()` and get only the +datagrams that came from that peer. + +`broadcast()` sends to every tracked peer. There is **no peer +discovery** — a peer exists only after it has spoken to the server. + +## Quick reference + +```php +use InitPHP\Socket\Socket; +use InitPHP\Socket\Enum\Transport; + +$server = Socket::server(Transport::UDP, '0.0.0.0', 9000); +$server->listen(); + +$server->live(function ($srv, $conn) { + $payload = $conn->read(65535); + if ($payload !== null) { + $conn->write("pong: {$payload}"); + } +}); +``` + +## Datagram sizing + +The internal read size is `UdpChannel::MAX_DATAGRAM = 65535`. The +practical upper bound for a UDP payload over IPv4 is **65 507 bytes**; +keep messages comfortably under MTU (≈1472 bytes on Ethernet) if you +care about avoiding fragmentation. + +## No backlog, no listen() call + +UDP doesn't have a listen queue, so `backlog()` does not exist. The +`listen()` method only performs `socket_create` + `socket_bind` for +this transport. + +## Caveats + +- **No reliability, no ordering.** That's UDP. If your protocol needs + retransmits, build them on top. +- **Peer eviction is your job.** The server never removes a UDP + connection on its own — you decide when a silent peer has gone away + (heartbeat, TTL, etc.) and call `$conn->close()`. +- **No `isAlive()` truth.** `UdpChannel::isAlive()` simply tracks + whether you have closed it; the protocol cannot tell you the peer is + gone. diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..61488ed --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,8 @@ +parameters: + level: 8 + paths: + - src + treatPhpDocTypesAsCertain: false + reportUnmatchedIgnoredErrors: true + tmpDir: .phpstan-cache + ignoreErrors: [] diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..6f6ce5c --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + tests/Unit + + + tests/Integration + + + + + src + + + + + + + diff --git a/src/Channel/StreamChannel.php b/src/Channel/StreamChannel.php new file mode 100644 index 0000000..6b5ca39 --- /dev/null +++ b/src/Channel/StreamChannel.php @@ -0,0 +1,78 @@ +stream = $stream; + } + + public function read(int $length = 1024, ?int $flag = null): ?string + { + if ($length < 1 || !\is_resource($this->stream)) { + return null; + } + $bytes = @fread($this->stream, $length); + if ($bytes === false || $bytes === '') { + return null; + } + + return $bytes; + } + + public function write(string $data): ?int + { + if (!\is_resource($this->stream)) { + return null; + } + $written = @fwrite($this->stream, $data, \strlen($data)); + + return $written === false ? null : $written; + } + + public function close(): bool + { + if (!\is_resource($this->stream)) { + $this->stream = null; + + return true; + } + @fclose($this->stream); + $this->stream = null; + + return true; + } + + public function isAlive(): bool + { + return \is_resource($this->stream) && !feof($this->stream); + } + + /** + * @return resource|null + */ + public function getResource(): mixed + { + return $this->stream; + } +} diff --git a/src/Channel/TcpChannel.php b/src/Channel/TcpChannel.php new file mode 100644 index 0000000..7635a2e --- /dev/null +++ b/src/Channel/TcpChannel.php @@ -0,0 +1,88 @@ +socket = $socket; + } + + public function read(int $length = 1024, ?int $flag = null): ?string + { + if ($this->socket === null) { + return null; + } + $flag ??= PHP_BINARY_READ; + $bytes = @socket_read($this->socket, $length, $flag); + if ($bytes === false || $bytes === '') { + return null; + } + + return $bytes; + } + + public function write(string $data): ?int + { + if ($this->socket === null) { + return null; + } + $written = @socket_write($this->socket, $data, \strlen($data)); + + return $written === false ? null : $written; + } + + public function close(): bool + { + if ($this->socket === null) { + return true; + } + @socket_close($this->socket); + $this->socket = null; + + return true; + } + + public function isAlive(): bool + { + if ($this->socket === null) { + return false; + } + $buffer = ''; + $result = @socket_recv($this->socket, $buffer, 1, MSG_PEEK | MSG_DONTWAIT); + if ($result === 0) { + return false; + } + if ($result === false) { + $err = socket_last_error($this->socket); + + return \in_array($err, [SOCKET_EAGAIN, SOCKET_EWOULDBLOCK], true); + } + + return true; + } + + public function getResource(): ?Socket + { + return $this->socket; + } +} diff --git a/src/Channel/UdpChannel.php b/src/Channel/UdpChannel.php new file mode 100644 index 0000000..384559a --- /dev/null +++ b/src/Channel/UdpChannel.php @@ -0,0 +1,99 @@ +socket = $listeningSocket; + } + + /** + * Append data routed by the UDP server for this peer. + */ + public function feed(string $data): void + { + $this->buffer .= $data; + } + + public function read(int $length = 1024, ?int $flag = null): ?string + { + if ($this->buffer === '') { + return null; + } + $chunk = substr($this->buffer, 0, $length); + $this->buffer = substr($this->buffer, \strlen($chunk)); + + return $chunk; + } + + public function write(string $data): ?int + { + if ($this->socket === null) { + return null; + } + $sent = @socket_sendto($this->socket, $data, \strlen($data), 0, $this->peerHost, $this->peerPort); + + return $sent === false ? null : $sent; + } + + public function close(): bool + { + $this->socket = null; + $this->alive = false; + $this->buffer = ''; + + return true; + } + + public function isAlive(): bool + { + return $this->alive && $this->socket !== null; + } + + public function getResource(): ?Socket + { + return $this->socket; + } + + public function getPeerHost(): string + { + return $this->peerHost; + } + + public function getPeerPort(): int + { + return $this->peerPort; + } + + public function peerKey(): string + { + return $this->peerHost . ':' . $this->peerPort; + } +} diff --git a/src/Client/AbstractClient.php b/src/Client/AbstractClient.php new file mode 100644 index 0000000..510025a --- /dev/null +++ b/src/Client/AbstractClient.php @@ -0,0 +1,33 @@ + 65535) { + throw new SocketInvalidArgumentException('Client port must be between 1 and 65535.'); + } + } + + public function getHost(): string + { + return $this->host; + } + + public function getPort(): int + { + return $this->port; + } +} diff --git a/src/Client/AbstractStreamClient.php b/src/Client/AbstractStreamClient.php new file mode 100644 index 0000000..4a5d317 --- /dev/null +++ b/src/Client/AbstractStreamClient.php @@ -0,0 +1,172 @@ + */ + protected array $options = []; + + protected ?float $timeout; + + protected bool $blocking = true; + + public function __construct( + string $host, + int $port, + protected readonly Transport $transport, + ?float $timeout = null, + ) { + parent::__construct($host, $port); + $this->timeout = $timeout; + } + + /** + * Set an SSL stream context option. + * + * @see https://www.php.net/manual/en/context.ssl.php + */ + public function option(string $key, mixed $value): static + { + $this->options[$key] = $value; + + return $this; + } + + public function timeout(float $seconds): static + { + $this->timeout = $seconds; + if (\is_resource($this->stream)) { + stream_set_timeout( + $this->stream, + (int) $seconds, + (int) (($seconds - (int) $seconds) * 1_000_000), + ); + } + + return $this; + } + + public function blocking(bool $mode = true): static + { + $this->blocking = $mode; + if (\is_resource($this->stream)) { + stream_set_blocking($this->stream, $mode); + } + + return $this; + } + + public function crypto(?CryptoMethod $method): static + { + if (!\is_resource($this->stream)) { + throw new SocketException('Cannot toggle crypto before connect().'); + } + if ($method === null) { + @stream_socket_enable_crypto($this->stream, false); + } else { + @stream_socket_enable_crypto($this->stream, true, $method->forClient()); + } + + return $this; + } + + public function connect(): static + { + if (\is_resource($this->stream)) { + throw new SocketException('Client is already connected.'); + } + $address = $this->transport->scheme() . '://' . $this->host . ':' . $this->port; + $errNo = 0; + $errStr = ''; + $timeout = $this->timeout ?? (float) \ini_get('default_socket_timeout'); + $context = stream_context_create(['ssl' => $this->options]); + $stream = @stream_socket_client( + $address, + $errNo, + $errStr, + $timeout, + STREAM_CLIENT_CONNECT, + $context, + ); + if ($stream === false) { + throw new SocketConnectionException( + \sprintf('stream_socket_client failed (%d): %s', $errNo, $errStr !== '' ? $errStr : 'unknown error'), + ); + } + stream_set_blocking($stream, $this->blocking); + if ($this->timeout !== null) { + stream_set_timeout( + $stream, + (int) $this->timeout, + (int) (($this->timeout - (int) $this->timeout) * 1_000_000), + ); + } + $this->stream = $stream; + + return $this; + } + + public function disconnect(): bool + { + if (!\is_resource($this->stream)) { + $this->stream = null; + + return true; + } + @fclose($this->stream); + $this->stream = null; + + return true; + } + + public function read(int $length = 1024): ?string + { + if ($length < 1 || !\is_resource($this->stream) || feof($this->stream)) { + return null; + } + $bytes = @fread($this->stream, $length); + if ($bytes === false || $bytes === '') { + return null; + } + + return $bytes; + } + + public function write(string $data): ?int + { + if (!\is_resource($this->stream)) { + return null; + } + $written = @fwrite($this->stream, $data, \strlen($data)); + + return $written === false ? null : $written; + } + + public function getSocket(): mixed + { + return $this->stream; + } +} diff --git a/src/Client/SSL.php b/src/Client/SSL.php index ce65053..c3a216e 100644 --- a/src/Client/SSL.php +++ b/src/Client/SSL.php @@ -1,28 +1,15 @@ - * @copyright Copyright © 2022 InitPHP - * @license http://initphp.github.io/license.txt MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ declare(strict_types=1); namespace InitPHP\Socket\Client; -use \InitPHP\Socket\Common\{StreamClientTrait, BaseClient}; -use \InitPHP\Socket\Interfaces\SocketClientInterface; +use InitPHP\Socket\Enum\Transport; -class SSL extends BaseClient implements SocketClientInterface +final class SSL extends AbstractStreamClient { - - use StreamClientTrait; - - protected string $type = 'ssl'; - + public function __construct(string $host, int $port, ?float $timeout = null) + { + parent::__construct($host, $port, Transport::SSL, $timeout); + } } diff --git a/src/Client/TCP.php b/src/Client/TCP.php index 9863172..99fe608 100644 --- a/src/Client/TCP.php +++ b/src/Client/TCP.php @@ -1,81 +1,94 @@ - * @copyright Copyright © 2022 InitPHP - * @license http://initphp.github.io/license.txt MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ declare(strict_types=1); namespace InitPHP\Socket\Client; -use \InitPHP\Socket\Exception\{SocketConnectionException, SocketInvalidArgumentException}; -use \InitPHP\Socket\Common\BaseClient; -use \InitPHP\Socket\Interfaces\SocketClientInterface; +use InitPHP\Socket\Enum\Domain; +use InitPHP\Socket\Exception\SocketConnectionException; +use InitPHP\Socket\Exception\SocketException; +use Socket; -use const PHP_BINARY_READ; -use const SOCK_STREAM; - -use function is_string; -use function socket_connect; +use function getprotobyname; use function socket_close; +use function socket_connect; +use function socket_create; +use function socket_last_error; use function socket_read; +use function socket_strerror; use function socket_write; -use function strlen; -class TCP extends BaseClient implements SocketClientInterface -{ +use const PHP_BINARY_READ; +use const SOCK_STREAM; - protected ?string $domain; +final class TCP extends AbstractClient +{ + private ?Socket $socket = null; - /** - * @param string $host - * @param int $port - * @param $argument

domain

- */ - public function __construct(string $host, int $port, $argument) - { - $this->setHost($host)->setPort($port); - if($argument !== null && !is_string($argument)){ - throw new SocketInvalidArgumentException('The TCP client must have a value pointing to the argument domain. Only "v4", "v6" or "unix"'); - } - $this->domain = $argument; + public function __construct( + string $host, + int $port, + private readonly Domain $domain = Domain::V4, + ) { + parent::__construct($host, $port); } - public function connection(): self + public function connect(): static { - $socket = $this->createSocketSource('tcp', SOCK_STREAM, $this->domain); - if(socket_connect($socket, $this->getHost(), $this->getPort()) === FALSE){ - throw new SocketConnectionException('Socket Connection Error : ' . $this->getLastError()); + if ($this->socket !== null) { + throw new SocketException('Client is already connected.'); + } + $proto = getprotobyname('tcp'); + $socket = @socket_create($this->domain->toAddressFamily(), SOCK_STREAM, $proto === false ? 0 : $proto); + if (!$socket instanceof Socket) { + throw new SocketException('socket_create failed: ' . socket_strerror(socket_last_error())); + } + if (@socket_connect($socket, $this->host, $this->port) === false) { + $err = socket_strerror(socket_last_error($socket)); + socket_close($socket); + throw new SocketConnectionException('socket_connect failed: ' . $err); } $this->socket = $socket; + return $this; } public function disconnect(): bool { - if(isset($this->socket)){ - socket_close($this->socket); + if ($this->socket === null) { + return true; } + @socket_close($this->socket); + $this->socket = null; + return true; } public function read(int $length = 1024, int $type = PHP_BINARY_READ): ?string { - $read = socket_read($this->getSocket(), $length, $type); - return $read === FALSE ? null : $read; + if ($this->socket === null) { + return null; + } + $bytes = @socket_read($this->socket, $length, $type); + if ($bytes === false || $bytes === '') { + return null; + } + + return $bytes; } - public function write(string $string): ?int + public function write(string $data): ?int { - $write = socket_write($this->getSocket(), $string, strlen($string)); - return $write === FALSE ? null : $write; + if ($this->socket === null) { + return null; + } + $written = @socket_write($this->socket, $data, \strlen($data)); + + return $written === false ? null : $written; } + public function getSocket(): ?Socket + { + return $this->socket; + } } diff --git a/src/Client/TLS.php b/src/Client/TLS.php index 1bfec62..6aa3ec0 100644 --- a/src/Client/TLS.php +++ b/src/Client/TLS.php @@ -1,28 +1,15 @@ - * @copyright Copyright © 2022 InitPHP - * @license http://initphp.github.io/license.txt MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ declare(strict_types=1); namespace InitPHP\Socket\Client; -use \InitPHP\Socket\Common\{StreamClientTrait, BaseClient}; -use \InitPHP\Socket\Interfaces\SocketClientInterface; +use InitPHP\Socket\Enum\Transport; -class TLS extends BaseClient implements SocketClientInterface +final class TLS extends AbstractStreamClient { - - use StreamClientTrait; - - protected string $type = 'tls'; - + public function __construct(string $host, int $port, ?float $timeout = null) + { + parent::__construct($host, $port, Transport::TLS, $timeout); + } } diff --git a/src/Client/UDP.php b/src/Client/UDP.php index 0908828..7f7a0d3 100644 --- a/src/Client/UDP.php +++ b/src/Client/UDP.php @@ -1,97 +1,100 @@ - * @copyright Copyright © 2022 InitPHP - * @license http://initphp.github.io/license.txt MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ declare(strict_types=1); namespace InitPHP\Socket\Client; -use \InitPHP\Socket\Exception\{SocketConnectionException, SocketInvalidArgumentException}; -use \InitPHP\Socket\Common\BaseClient; -use \InitPHP\Socket\Interfaces\SocketClientInterface; +use InitPHP\Socket\Enum\Domain; +use InitPHP\Socket\Exception\SocketConnectionException; +use InitPHP\Socket\Exception\SocketException; +use Socket; -use const SOCK_DGRAM; - -use function is_string; -use function socket_connect; +use function getprotobyname; use function socket_close; -use function socket_recvfrom; -use function socket_sendto; -use function strlen; +use function socket_connect; +use function socket_create; +use function socket_last_error; +use function socket_recv; +use function socket_send; +use function socket_strerror; -class UDP extends BaseClient implements SocketClientInterface -{ +use const SOCK_DGRAM; - protected ?string $domain; +final class UDP extends AbstractClient +{ + private ?Socket $socket = null; - /** - * @param string $host - * @param int $port - * @param $argument

domain

- */ - public function __construct(string $host, int $port, $argument) - { - $this->setHost($host)->setPort($port); - if($argument !== null && !is_string($argument)){ - throw new SocketInvalidArgumentException('The UDP client must have a value pointing to the argument domain. Only "v4", "v6" or "unix"'); - } - $this->domain = $argument; + public function __construct( + string $host, + int $port, + private readonly Domain $domain = Domain::V4, + ) { + parent::__construct($host, $port); } - public function connection(): self + public function connect(): static { - $socket = $this->createSocketSource('udp', SOCK_DGRAM, $this->domain); - $host = $this->getHost(); - $port = $this->getPort(); - if(socket_connect($socket, $host, $port) === FALSE){ - throw new SocketConnectionException('Socket could not be connected. #' . $this->getLastError()); + if ($this->socket !== null) { + throw new SocketException('Client is already connected.'); + } + $proto = getprotobyname('udp'); + $socket = @socket_create($this->domain->toAddressFamily(), SOCK_DGRAM, $proto === false ? 0 : $proto); + if (!$socket instanceof Socket) { + throw new SocketException('socket_create failed: ' . socket_strerror(socket_last_error())); + } + if (@socket_connect($socket, $this->host, $this->port) === false) { + $err = socket_strerror(socket_last_error($socket)); + socket_close($socket); + throw new SocketConnectionException('socket_connect failed: ' . $err); } $this->socket = $socket; - $this->host = $host; - $this->port = $port; + return $this; } public function disconnect(): bool { - if(isset($this->socket)){ - socket_close($this->socket); + if ($this->socket === null) { + return true; } + @socket_close($this->socket); + $this->socket = null; + return true; } /** - * @param int $length - * @param int $type

\MSG_OOB, \MSG_PEEK, \MSG_WAITALL or \MSG_DONTWAIT consts

- * @return string|null + * @param int $flags Bitmask of MSG_OOB, MSG_PEEK, MSG_WAITALL, MSG_DONTWAIT */ - public function read(int $length = 1024, int $type = 0): ?string + public function read(int $length = 1024, int $flags = 0): ?string { - $read = socket_recvfrom($this->getSocket(), $content, $length, $type, $name, $port); - if($read === FALSE || empty($content)){ + if ($this->socket === null || $length < 1) { return null; } - return $content; + $buf = ''; + $bytes = @socket_recv($this->socket, $buf, $length, $flags); + if ($bytes === false || $bytes === 0) { + return null; + } + + return $buf; } /** - * @param string $string - * @param int $type

\MSG_OOB, \MSG_EOR, \MSG_EOF or \MSG_DONTROUTE consts

- * @return int|null + * @param int $flags Bitmask of MSG_OOB, MSG_EOR, MSG_EOF, MSG_DONTROUTE */ - public function write(string $string, int $type = 0): ?int + public function write(string $data, int $flags = 0): ?int { - $write = socket_sendto($this->getSocket(), $string, strlen($string), $type, $this->getHost(), $this->getPort()); - return $write === FALSE ? null : $write; + if ($this->socket === null) { + return null; + } + $sent = @socket_send($this->socket, $data, \strlen($data), $flags); + + return $sent === false ? null : $sent; } + public function getSocket(): ?Socket + { + return $this->socket; + } } diff --git a/src/Common/BaseClient.php b/src/Common/BaseClient.php deleted file mode 100644 index 7998e43..0000000 --- a/src/Common/BaseClient.php +++ /dev/null @@ -1,32 +0,0 @@ - - * @copyright Copyright © 2022 InitPHP - * @license http://initphp.github.io/license.txt MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); - -namespace InitPHP\Socket\Common; - -use InitPHP\Socket\Interfaces\SocketClientInterface; - -abstract class BaseClient implements SocketClientInterface -{ - use BaseCommon; - - abstract public function connection(): SocketClientInterface; - - abstract public function disconnect(): bool; - - abstract public function read(int $length = 1024): ?string; - - abstract public function write(string $string): ?int; - -} diff --git a/src/Common/BaseCommon.php b/src/Common/BaseCommon.php deleted file mode 100644 index fada172..0000000 --- a/src/Common/BaseCommon.php +++ /dev/null @@ -1,107 +0,0 @@ - - * @copyright Copyright © 2022 InitPHP - * @license http://initphp.github.io/license.txt MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); - -namespace InitPHP\Socket\Common; - -use InitPHP\Socket\Exception\{SocketException, SocketInvalidArgumentException}; - -use const AF_INET; -use const AF_INET6; -use const AF_UNIX; - -use function getprotobyname; -use function socket_create; -use function socket_last_error; -use function socket_bind; - -trait BaseCommon -{ - /** @var resource */ - protected $socket; - - protected string $host; - protected int $port; - - - protected array $domains = [ - 'v4' => AF_INET, - 'v6' => AF_INET6, - 'unix' => AF_UNIX, - ]; - - public function setHost(string $host): self - { - $this->host = $host; - return $this; - } - - public function getHost(): string - { - if(!isset($this->host)){ - throw new SocketException('It cannot be used without the "host" being defined.'); - } - return $this->host; - } - - public function setPort(int $port): self - { - $this->port = $port; - return $this; - } - - public function getPort(): int - { - if(!isset($this->port)){ - throw new SocketException('It cannot be used without a "port" defined.'); - } - return $this->port; - } - - /** - * @inheritDoc - */ - public function getSocket() - { - if(!isset($this->socket)){ - throw new SocketException('The socket cannot be reachable before the connection is made.'); - } - - return $this->socket; - } - - - protected function createSocketSource($protocol, $type, $domain) - { - $domain = empty($domain) ? 'v4' : $domain; - $protocol = getprotobyname($protocol); - if(!isset($this->domains[$domain])){ - throw new SocketInvalidArgumentException('Socket resource creation failed! Reason: Invalid domain. Only "v4", "v6" or "unix"'); - } - return socket_create($this->domains[$domain], $type, $protocol); - } - - protected function getLastError(): int - { - return socket_last_error(); - } - - protected function socketBind(&$socket, &$host, &$port) - { - if(socket_bind($socket, $host, $port) === FALSE){ - throw new SocketException('SocketBind Error : ' . socket_last_error()); - } - } - -} diff --git a/src/Common/BaseServer.php b/src/Common/BaseServer.php deleted file mode 100644 index d298395..0000000 --- a/src/Common/BaseServer.php +++ /dev/null @@ -1,125 +0,0 @@ - - * @copyright Copyright © 2022 InitPHP - * @license http://initphp.github.io/license.txt MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); - -namespace InitPHP\Socket\Common; - -use InitPHP\Socket\Interfaces\SocketServerClientInterface; -use InitPHP\Socket\Interfaces\SocketServerInterface; - -use InitPHP\Socket\Server\ServerClient; -use function sleep; -use function usleep; -use function call_user_func_array; -use function socket_accept; -use function array_search; -use function is_iterable; -use function is_int; - -abstract class BaseServer implements SocketServerInterface -{ - use BaseCommon; - - /** @var SocketServerClientInterface[] */ - protected array $clients = []; - - /** @var array */ - protected array $clientMap = []; - - abstract public function connection(): SocketServerInterface; - - abstract public function disconnect(): bool; - - public function getClients(): array - { - return $this->clients; - } - - public function clientRegister($id, SocketServerClientInterface $client): bool - { - try { - $index = array_search($client, $this->clients); - if ($index === false) { - return false; - } - $this->clientMap[$id] = $index; - $this->clients[$index]->setId($id); - - return true; - } catch (\Throwable $e) { - return false; - } - } - - /** - * @param string $message - * @param array|string|int|null $clients - * @return bool - */ - public function broadcast(string $message, $clients = null): bool - { - try { - if ($clients !== null) { - !is_iterable($clients) && $clients = [$clients]; - foreach ($clients as $id) { - isset($this->clients[$this->clientMap[$id]]) && $this->clients[$this->clientMap[$id]]->push($message); - } - } else { - foreach ($this->clients as $address => $client) { - $client->push($message); - } - } - - return true; - } catch (\Throwable $e) { - return false; - } - } - - /** - * @inheritDoc - */ - public function live(callable $callback, int $usleep = 100000): void - { - while (true) { - if ($clientSocket = socket_accept($this->socket)) { - $client = (new ServerClient())->__setSocket($clientSocket); - $this->clients[] = $client; - } - foreach ($this->clients as $index => $client) { - if ($client->isDisconnected()) { - unset($this->clients[$index]); - continue; - } - call_user_func_array($callback, [$this, $client]); - } - - $usleep < 1000 && $usleep = 1000; - $this->wait($usleep / 1000000); - } - } - - public function wait($second): void - { - if ($second < 0) { - throw new \InvalidArgumentException("Waiting time cannot be less than 0."); - } - if (is_int($second)) { - sleep($second); - } else { - usleep($second * 1000000); - } - } - -} diff --git a/src/Common/ServerTrait.php b/src/Common/ServerTrait.php deleted file mode 100644 index 8d1c34a..0000000 --- a/src/Common/ServerTrait.php +++ /dev/null @@ -1,36 +0,0 @@ - - * @copyright Copyright © 2022 InitPHP - * @license http://initphp.github.io/license.txt MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); - -namespace InitPHP\Socket\Common; - -use InitPHP\Socket\Exception\SocketInvalidArgumentException; - -use function is_string; - -trait ServerTrait -{ - - protected ?string $domain; - - public function __construct($host, $port, $argument) - { - $this->setHost($host)->setPort($port); - if($argument !== null && !is_string($argument)){ - throw new SocketInvalidArgumentException('For UDP and TCP servers, the argument must be a string specifying the domain. Only "v4", "v6" or "unix"'); - } - $this->domain = $argument; - } - -} diff --git a/src/Common/StreamClientTrait.php b/src/Common/StreamClientTrait.php deleted file mode 100644 index b0cd188..0000000 --- a/src/Common/StreamClientTrait.php +++ /dev/null @@ -1,136 +0,0 @@ - - * @copyright Copyright © 2022 InitPHP - * @license http://initphp.github.io/license.txt MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); - -namespace InitPHP\Socket\Common; - -use InitPHP\Socket\Exception\{SocketConnectionException, SocketException, SocketInvalidArgumentException}; - -use const STREAM_CRYPTO_METHOD_SSLv2_CLIENT; -use const STREAM_CRYPTO_METHOD_SSLv3_CLIENT; -use const STREAM_CRYPTO_METHOD_SSLv23_CLIENT; -use const STREAM_CRYPTO_METHOD_ANY_CLIENT; -use const STREAM_CRYPTO_METHOD_TLS_CLIENT; -use const STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT; -use const STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT; -use const STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT; - -use function is_float; -use function stream_socket_client; -use function stream_context_create; -use function fclose; -use function fread; -use function fwrite; -use function stream_set_timeout; -use function stream_set_blocking; -use function stream_socket_enable_crypto; -use function strtolower; -use function implode; -use function array_keys; - -trait StreamClientTrait -{ - protected ?float $timeout = null; - - protected array $options = []; - - public function __construct(string $host, int $port, $argument) - { - $this->setHost($host)->setPort($port); - if($argument !== null && !is_float($argument)){ - throw new SocketInvalidArgumentException('For SSL and TLS clients, the argument must be a float specifying the timeout.'); - } - $this->timeout = $argument; - } - - public function connection(): self - { - $address = $this->type . '://' . $this->getHost() . ':' . $this->getPort(); - $socket = stream_socket_client($address, $errNo, $errStr, $this->timeout, STREAM_CLIENT_CONNECT, stream_context_create(['ssl' => $this->options])); - if($socket === FALSE || !empty($errStr)){ - throw new SocketConnectionException('Socket Connection Error : ' . $errStr); - } - $this->socket = $socket; - return $this; - } - - public function disconnect(): bool - { - if(isset($this->socket)){ - return (bool)fclose($this->socket); - } - return true; - } - - public function read(int $length = 1024): ?string - { - $read = fread($this->getSocket(), $length); - return $read === FALSE ? null : $read; - } - - public function write(string $string): ?int - { - $write = fwrite($this->getSocket(), $string, strlen($string)); - return $write === FALSE ? null : $write; - } - - public function timeout(int $second): self - { - stream_set_timeout($this->getSocket(), $second); - return $this; - } - - public function blocking(bool $mode = true): self - { - stream_set_blocking($this->getSocket(), $mode); - return $this; - } - - public function crypto(?string $method = null): self - { - if(empty($method)){ - stream_socket_enable_crypto($this->getSocket(), false); - return $this; - } - $method = strtolower($method); - $algos = [ - 'sslv2' => STREAM_CRYPTO_METHOD_SSLv2_CLIENT, - 'sslv3' => STREAM_CRYPTO_METHOD_SSLv3_CLIENT, - 'sslv23' => STREAM_CRYPTO_METHOD_SSLv23_CLIENT, - 'any' => STREAM_CRYPTO_METHOD_ANY_CLIENT, - 'tls' => STREAM_CRYPTO_METHOD_TLS_CLIENT, - 'tlsv1.0' => STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT, - 'tlsv1.1' => STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT, - 'tlsv1.2' => STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT, - ]; - if(!isset($algos[$method])){ - throw new SocketException('Unsupported crypto method. This library supports: ' . implode(', ', array_keys($algos))); - } - stream_socket_enable_crypto($this->getSocket(), true, $algos[$method]); - return $this; - } - - /** - * @link https://www.php.net/manual/tr/context.ssl.php - * @param string $key - * @param mixed $value - * @return $this - */ - public function option(string $key, $value): self - { - $this->options[$key] = $value; - return $this; - } - -} diff --git a/src/Common/StreamServerTrait.php b/src/Common/StreamServerTrait.php deleted file mode 100644 index f0a8c1c..0000000 --- a/src/Common/StreamServerTrait.php +++ /dev/null @@ -1,173 +0,0 @@ - - * @copyright Copyright © 2022 InitPHP - * @license http://initphp.github.io/license.txt MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); - -namespace InitPHP\Socket\Common; - -use InitPHP\Socket\Server\ServerClient; -use InitPHP\Socket\Socket; -use InitPHP\Socket\Exception\{SocketConnectionException, SocketException, SocketInvalidArgumentException}; - -use const STREAM_CRYPTO_METHOD_SSLv2_SERVER; -use const STREAM_CRYPTO_METHOD_SSLv3_SERVER; -use const STREAM_CRYPTO_METHOD_SSLv23_SERVER; -use const STREAM_CRYPTO_METHOD_ANY_SERVER; -use const STREAM_CRYPTO_METHOD_TLS_SERVER; -use const STREAM_CRYPTO_METHOD_TLSv1_0_SERVER; -use const STREAM_CRYPTO_METHOD_TLSv1_1_SERVER; -use const STREAM_CRYPTO_METHOD_TLSv1_2_SERVER; - -use function is_float; -use function stream_socket_server; -use function stream_context_create; -use function ini_get; -use function stream_socket_accept; -use function fclose; -use function stream_set_timeout; -use function stream_set_blocking; -use function stream_socket_enable_crypto; -use function strtolower; -use function implode; -use function array_keys; - -trait StreamServerTrait -{ - protected ?float $timeout = null; - - protected array $options = []; - - public function __construct(string $host, int $port, $argument) - { - $this->setHost($host)->setPort($port); - if($argument !== null && !is_float($argument)){ - throw new SocketInvalidArgumentException('For SSL and TLS servers, the argument must be a float specifying the timeout.'); - } - $this->timeout = $argument; - } - - - public function connection(): self - { - $address = $this->type . '://' . $this->getHost() . ':' . $this->getPort(); - $socket = stream_socket_server($address, $errNo, $errStr, STREAM_SERVER_BIND|STREAM_SERVER_LISTEN, stream_context_create(['ssl' => $this->options])); - if($socket === FALSE || !empty($errStr)){ - throw new SocketConnectionException('Connection Error : ' . $errStr); - } - $timeout = empty($this->timeout) ? (int)ini_get('default_socket_timeout') : $this->timeout; - - if(($accept = stream_socket_accept($socket, $timeout)) === FALSE){ - throw new SocketConnectionException('Connection Error : ' . $errStr); - } - $this->socket = $socket; - - $this->clients[] = (new ServerClient([ - 'type' => $this->type === 'tls' ? Socket::TLS : Socket::SSL, - 'host' => $this->getHost(), - 'port' => $this->getPort(), - ]))->__setSocket($accept); - - return $this; - } - - public function disconnect(): bool - { - if (!empty($this->clients)) { - foreach ($this->clients as $client) { - $client->close(); - } - } - - if(!empty($this->socket)){ - fclose($this->socket); - } - - return true; - } - - public function timeout(int $second): self - { - if (!empty($this->clients)) { - foreach ($this->clients as $client) { - stream_set_timeout($client->getSocket(), $second); - } - ServerClient::__setCallbacks('stream_set_timeout', ['{socket}', $second]); - } - - return $this; - } - - public function blocking(bool $mode = true): self - { - if (!empty($this->clients)) { - foreach ($this->clients as $client) { - stream_set_blocking($client->getSocket(), $mode); - } - ServerClient::__setCallbacks('stream_set_blocking', ['{socket}', $mode]); - } - - - return $this; - } - - public function crypto(?string $method = null): self - { - if(empty($method)){ - if (!empty($this->clients)) { - foreach ($this->clients as $client) { - stream_socket_enable_crypto($client->getSocket(), false); - } - ServerClient::__setCallbacks('stream_socket_enable_crypto', ['{socket}', false]); - } - - return $this; - } - $method = strtolower($method); - $algos = [ - 'sslv2' => STREAM_CRYPTO_METHOD_SSLv2_SERVER, - 'sslv3' => STREAM_CRYPTO_METHOD_SSLv3_SERVER, - 'sslv23' => STREAM_CRYPTO_METHOD_SSLv23_SERVER, - 'any' => STREAM_CRYPTO_METHOD_ANY_SERVER, - 'tls' => STREAM_CRYPTO_METHOD_TLS_SERVER, - 'tlsv1.0' => STREAM_CRYPTO_METHOD_TLSv1_0_SERVER, - 'tlsv1.1' => STREAM_CRYPTO_METHOD_TLSv1_1_SERVER, - 'tlsv1.2' => STREAM_CRYPTO_METHOD_TLSv1_2_SERVER - ]; - if(!isset($algos[$method])){ - throw new SocketException('Unsupported crypto method. This library supports: ' . implode(', ', array_keys($algos))); - } - - if (!empty($this->clients)) { - foreach ($this->clients as $client) { - stream_socket_enable_crypto($client->getSocket(), true, $algos[$method]); - } - ServerClient::__setCallbacks('stream_socket_enable_crypto', ['{socket}', true, $algos[$method]]); - } - - return $this; - } - - - /** - * @link https://www.php.net/manual/tr/context.ssl.php - * @param string $key - * @param mixed $value - * @return $this - */ - public function option(string $key, $value): self - { - $this->options[$key] = $value; - return $this; - } - -} diff --git a/src/Enum/CryptoMethod.php b/src/Enum/CryptoMethod.php new file mode 100644 index 0000000..343f7a6 --- /dev/null +++ b/src/Enum/CryptoMethod.php @@ -0,0 +1,78 @@ + STREAM_CRYPTO_METHOD_SSLv2_CLIENT, + self::SSLv3 => STREAM_CRYPTO_METHOD_SSLv3_CLIENT, + self::SSLv23 => STREAM_CRYPTO_METHOD_SSLv23_CLIENT, + self::ANY => STREAM_CRYPTO_METHOD_ANY_CLIENT, + self::TLS => STREAM_CRYPTO_METHOD_TLS_CLIENT, + self::TLSv1_0 => STREAM_CRYPTO_METHOD_TLSv1_0_CLIENT, + self::TLSv1_1 => STREAM_CRYPTO_METHOD_TLSv1_1_CLIENT, + self::TLSv1_2 => STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT, + }; + } + + public function forServer(): int + { + return match ($this) { + self::SSLv2 => STREAM_CRYPTO_METHOD_SSLv2_SERVER, + self::SSLv3 => STREAM_CRYPTO_METHOD_SSLv3_SERVER, + self::SSLv23 => STREAM_CRYPTO_METHOD_SSLv23_SERVER, + self::ANY => STREAM_CRYPTO_METHOD_ANY_SERVER, + self::TLS => STREAM_CRYPTO_METHOD_TLS_SERVER, + self::TLSv1_0 => STREAM_CRYPTO_METHOD_TLSv1_0_SERVER, + self::TLSv1_1 => STREAM_CRYPTO_METHOD_TLSv1_1_SERVER, + self::TLSv1_2 => STREAM_CRYPTO_METHOD_TLSv1_2_SERVER, + }; + } + + public static function fromName(string $name): self + { + $case = self::tryFrom(strtolower($name)); + if ($case === null) { + throw new SocketInvalidArgumentException(\sprintf( + 'Unsupported crypto method "%s". Expected one of: %s.', + $name, + implode(', ', array_map(static fn (self $c): string => $c->value, self::cases())), + )); + } + + return $case; + } +} diff --git a/src/Enum/Domain.php b/src/Enum/Domain.php new file mode 100644 index 0000000..5a05eef --- /dev/null +++ b/src/Enum/Domain.php @@ -0,0 +1,43 @@ + AF_INET, + self::V6 => AF_INET6, + self::UNIX => AF_UNIX, + }; + } + + public static function fromName(?string $name): self + { + if ($name === null || $name === '') { + return self::V4; + } + $value = strtolower($name); + $case = self::tryFrom($value); + if ($case === null) { + throw new SocketInvalidArgumentException( + \sprintf('Unknown domain "%s". Expected one of: v4, v6, unix.', $name), + ); + } + + return $case; + } +} diff --git a/src/Enum/Transport.php b/src/Enum/Transport.php new file mode 100644 index 0000000..c626822 --- /dev/null +++ b/src/Enum/Transport.php @@ -0,0 +1,28 @@ +value; + } +} diff --git a/src/Exception/SocketConnectionException.php b/src/Exception/SocketConnectionException.php index 03727c1..9a9cd7d 100644 --- a/src/Exception/SocketConnectionException.php +++ b/src/Exception/SocketConnectionException.php @@ -1,20 +1,9 @@ - * @copyright Copyright © 2022 InitPHP - * @license http://initphp.github.io/license.txt MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ declare(strict_types=1); namespace InitPHP\Socket\Exception; -class SocketConnectionException extends \Exception +class SocketConnectionException extends SocketException { } diff --git a/src/Exception/SocketException.php b/src/Exception/SocketException.php index abfdb1d..3383f3d 100644 --- a/src/Exception/SocketException.php +++ b/src/Exception/SocketException.php @@ -1,20 +1,11 @@ - * @copyright Copyright © 2022 InitPHP - * @license http://initphp.github.io/license.txt MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ declare(strict_types=1); namespace InitPHP\Socket\Exception; -class SocketException extends \Exception +use RuntimeException; + +class SocketException extends RuntimeException implements SocketExceptionInterface { } diff --git a/src/Exception/SocketExceptionInterface.php b/src/Exception/SocketExceptionInterface.php new file mode 100644 index 0000000..18237e5 --- /dev/null +++ b/src/Exception/SocketExceptionInterface.php @@ -0,0 +1,11 @@ + - * @copyright Copyright © 2022 InitPHP - * @license http://initphp.github.io/license.txt MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ declare(strict_types=1); namespace InitPHP\Socket\Exception; -class SocketInvalidArgumentException extends \InvalidArgumentException +use InvalidArgumentException; + +class SocketInvalidArgumentException extends InvalidArgumentException implements SocketExceptionInterface { } diff --git a/src/Exception/SocketListenException.php b/src/Exception/SocketListenException.php index c81412a..51a988e 100644 --- a/src/Exception/SocketListenException.php +++ b/src/Exception/SocketListenException.php @@ -1,20 +1,9 @@ - * @copyright Copyright © 2022 InitPHP - * @license http://initphp.github.io/license.txt MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ declare(strict_types=1); namespace InitPHP\Socket\Exception; -class SocketListenException extends \Exception +class SocketListenException extends SocketException { } diff --git a/src/Interfaces/ChannelInterface.php b/src/Interfaces/ChannelInterface.php new file mode 100644 index 0000000..8112fbd --- /dev/null +++ b/src/Interfaces/ChannelInterface.php @@ -0,0 +1,50 @@ + - * @copyright Copyright © 2022 InitPHP - * @license http://initphp.github.io/license.txt MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ declare(strict_types=1); @@ -17,26 +6,37 @@ interface SocketClientInterface { - - public function setHost(string $host): SocketClientInterface; - public function getHost(): string; - public function setPort(int $port): SocketClientInterface; - public function getPort(): int; /** - * @return resource + * Native socket handle (\Socket for ext-sockets, stream resource for TLS/SSL). */ - public function getSocket(); + public function getSocket(): mixed; - public function connection(): SocketClientInterface; + /** + * Establish the connection to the remote endpoint. + */ + public function connect(): static; + /** + * Close the connection. Returns true on success, false on failure. + * Calling this on a non-connected client is a no-op and returns true. + */ public function disconnect(): bool; + /** + * Read up to $length bytes from the remote endpoint. + * + * Returns the payload, or null when nothing was read. + */ public function read(int $length = 1024): ?string; - public function write(string $string): ?int; - + /** + * Write $data to the remote endpoint. + * + * Returns the number of bytes actually written, or null on failure. + */ + public function write(string $data): ?int; } diff --git a/src/Interfaces/SocketConnectionInterface.php b/src/Interfaces/SocketConnectionInterface.php new file mode 100644 index 0000000..cbac864 --- /dev/null +++ b/src/Interfaces/SocketConnectionInterface.php @@ -0,0 +1,47 @@ + - * @copyright Copyright © 2022 InitPHP - * @license http://initphp.github.io/license.txt MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ declare(strict_types=1); @@ -17,51 +6,83 @@ interface SocketServerInterface { - public function setHost(string $host): SocketServerInterface; - public function getHost(): string; - public function setPort(int $port): SocketServerInterface; - public function getPort(): int; /** - * @return resource + * The listening socket. \Socket for ext-sockets servers, stream resource + * for TLS/SSL servers, or null before {@see self::listen()} succeeds. */ - public function getSocket(); + public function getSocket(): mixed; /** - * @return SocketServerClientInterface[] + * @return array */ public function getClients(): array; /** - * @return SocketServerInterface + * Bind to host:port and start listening. Does NOT accept any client — + * use {@see self::live()} to run the accept/dispatch loop. + */ + public function listen(): static; + + /** + * Stop listening and close every active client connection. */ - public function connection(): SocketServerInterface; + public function close(): bool; /** - * @return bool + * Send $message to one or more clients. + * + * - null → every connected client + * - int|string → the client previously registered with that id + * - array → multiple ids + * + * Returns true if at least one delivery was attempted, false on + * unrecoverable errors. + * + * @param int|string|array|null $clients */ - public function disconnect(): bool; + public function broadcast(string $message, int|string|array|null $clients = null): bool; /** - * @param string $message - * @return bool + * Associate an identifier with a connection so it can be addressed by + * {@see self::broadcast()}. */ - public function broadcast(string $message): bool; + public function register(int|string $id, SocketConnectionInterface $client): bool; /** - * @param callable $callback - * @param int $usleep - * @return void + * Run the accept/dispatch loop. Blocks until {@see self::stop()} is + * called or a signal interrupts it. + * + * The callback receives the server and the active connection; it is + * invoked whenever a connection has new inbound data (or, for UDP, + * whenever a datagram arrives). + * + * @param callable(SocketServerInterface, SocketConnectionInterface): void $callback */ - public function live(callable $callback, int $usleep = 100000): void; + public function live(callable $callback, float $idleSeconds = 0.05): void; /** - * @param int|float $second - * @return void + * Run a single iteration of the accept/dispatch loop. Returns the + * number of events processed during the tick (0 on idle). + * + * Useful for embedding the server inside another event loop or for + * deterministic testing. The waitSeconds argument is the maximum time + * the underlying select() may block while waiting for activity. + * + * @param callable(SocketServerInterface, SocketConnectionInterface): void $callback */ - public function wait($second): void; + public function tick(callable $callback, float $waitSeconds = 0.0): int; + /** + * Cooperatively stop the loop started by {@see self::live()}. + */ + public function stop(): void; + + /** + * Sleep for $seconds (supports sub-second precision). + */ + public function wait(float $seconds): void; } diff --git a/src/Server/AbstractServer.php b/src/Server/AbstractServer.php new file mode 100644 index 0000000..7ce5e7c --- /dev/null +++ b/src/Server/AbstractServer.php @@ -0,0 +1,191 @@ + */ + protected array $clients = []; + + /** @var array id → internal client key */ + protected array $clientIdMap = []; + + protected int $nextClientKey = 1; + + protected bool $running = false; + + public function __construct( + protected readonly string $host, + protected readonly int $port, + ) { + if ($host === '') { + throw new SocketInvalidArgumentException('Server host must not be empty.'); + } + if ($port <= 0 || $port > 65535) { + throw new SocketInvalidArgumentException('Server port must be between 1 and 65535.'); + } + } + + public function getHost(): string + { + return $this->host; + } + + public function getPort(): int + { + return $this->port; + } + + public function getClients(): array + { + /** @var array $map */ + $map = []; + foreach ($this->clients as $key => $client) { + $id = $client->getId(); + $map[$id ?? $key] = $client; + } + + return $map; + } + + public function register(int|string $id, SocketConnectionInterface $client): bool + { + $key = $this->indexOf($client); + if ($key === null) { + return false; + } + $this->clientIdMap[$id] = $key; + $client->setId($id); + + return true; + } + + public function broadcast(string $message, int|string|array|null $clients = null): bool + { + try { + if ($clients === null) { + foreach ($this->clients as $client) { + if ($client->isAlive()) { + $client->write($message); + } + } + + return true; + } + $ids = \is_array($clients) ? $clients : [$clients]; + foreach ($ids as $id) { + if (!isset($this->clientIdMap[$id])) { + continue; + } + $key = $this->clientIdMap[$id]; + $client = $this->clients[$key] ?? null; + if ($client !== null && $client->isAlive()) { + $client->write($message); + } + } + + return true; + } catch (Throwable) { + return false; + } + } + + public function stop(): void + { + $this->running = false; + } + + public function wait(float $seconds): void + { + if ($seconds < 0) { + throw new SocketInvalidArgumentException('Waiting time cannot be negative.'); + } + if ($seconds === 0.0) { + return; + } + usleep((int) ($seconds * 1_000_000)); + } + + abstract public function listen(): static; + + abstract public function close(): bool; + + abstract public function tick(callable $callback, float $waitSeconds = 0.0): int; + + abstract public function getSocket(): mixed; + + public function live(callable $callback, float $idleSeconds = 0.05): void + { + $this->running = true; + while ($this->isRunning()) { + $this->tick($callback, $idleSeconds); + } + } + + public function isRunning(): bool + { + return $this->running; + } + + /** + * Register a newly accepted connection. Returns its internal key. + */ + protected function addClient(SocketConnectionInterface $client): int + { + $key = $this->nextClientKey++; + $this->clients[$key] = $client; + + return $key; + } + + /** + * Drop a client from every registry. Does not close it — callers must + * close first if needed. + */ + protected function evict(int $key): void + { + unset($this->clients[$key]); + foreach ($this->clientIdMap as $id => $mappedKey) { + if ($mappedKey === $key) { + unset($this->clientIdMap[$id]); + } + } + } + + protected function indexOf(SocketConnectionInterface $client): ?int + { + foreach ($this->clients as $key => $existing) { + if ($existing === $client) { + return $key; + } + } + + return null; + } + + /** + * Split a fractional second count into (seconds, microseconds) for + * the *_select() family of functions. + * + * @return array{0: int, 1: int} + */ + protected static function splitSeconds(float $seconds): array + { + if ($seconds < 0) { + $seconds = 0.0; + } + $whole = (int) $seconds; + $usec = (int) (($seconds - $whole) * 1_000_000); + + return [$whole, $usec]; + } +} diff --git a/src/Server/AbstractStreamServer.php b/src/Server/AbstractStreamServer.php new file mode 100644 index 0000000..805120d --- /dev/null +++ b/src/Server/AbstractStreamServer.php @@ -0,0 +1,243 @@ + SSL context options */ + protected array $options = []; + + protected ?float $timeout = null; + + protected ?CryptoMethod $crypto = null; + + protected bool $blocking = false; + + public function __construct( + string $host, + int $port, + protected readonly Transport $transport, + ?float $timeout = null, + ) { + parent::__construct($host, $port); + $this->timeout = $timeout; + } + + /** + * Set an SSL stream context option. + * + * @see https://www.php.net/manual/en/context.ssl.php + */ + public function option(string $key, mixed $value): static + { + $this->options[$key] = $value; + + return $this; + } + + public function timeout(float $seconds): static + { + $this->timeout = $seconds; + + return $this; + } + + public function blocking(bool $mode = true): static + { + $this->blocking = $mode; + + return $this; + } + + public function crypto(?CryptoMethod $method): static + { + $this->crypto = $method; + if ($method !== null) { + $this->options['crypto_method'] = $method->forServer(); + } else { + unset($this->options['crypto_method']); + } + + return $this; + } + + public function listen(): static + { + if ($this->listenSocket !== null) { + throw new SocketException('Server is already listening.'); + } + $address = $this->transport->scheme() . '://' . $this->host . ':' . $this->port; + $errNo = 0; + $errStr = ''; + $context = stream_context_create(['ssl' => $this->options]); + $socket = @stream_socket_server( + $address, + $errNo, + $errStr, + STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, + $context, + ); + if ($socket === false) { + throw new SocketListenException( + \sprintf('stream_socket_server failed (%d): %s', $errNo, $errStr !== '' ? $errStr : 'unknown error'), + ); + } + // We deliberately leave the listening stream in its default blocking mode: + // stream_select() drives readiness, and a non-blocking listen prevents + // stream_socket_accept() from completing the TLS/SSL handshake within + // the timeout we pass. + $this->listenSocket = $socket; + + return $this; + } + + public function close(): bool + { + foreach ($this->clients as $client) { + $client->close(); + } + $this->clients = []; + $this->clientIdMap = []; + if (\is_resource($this->listenSocket)) { + @fclose($this->listenSocket); + } + $this->listenSocket = null; + $this->running = false; + + return true; + } + + public function tick(callable $callback, float $waitSeconds = 0.0): int + { + if (!\is_resource($this->listenSocket)) { + throw new SocketException('Server is not listening. Call listen() first.'); + } + [$sec, $usec] = self::splitSeconds($waitSeconds); + $read = $this->buildReadSet(); + $write = null; + $except = null; + $ready = @stream_select($read, $write, $except, $sec, $usec); + if ($ready === false) { + throw new SocketException('stream_select failed.'); + } + if ($ready === 0) { + return 0; + } + $events = 0; + foreach ($read as $resource) { + if ($resource === $this->listenSocket) { + $this->acceptNew(); + ++$events; + continue; + } + $this->serviceReadable($resource, $callback); + ++$events; + } + + return $events; + } + + public function getSocket(): mixed + { + return $this->listenSocket; + } + + /** + * @return array + */ + private function buildReadSet(): array + { + $set = []; + if (\is_resource($this->listenSocket)) { + $set[] = $this->listenSocket; + } + foreach ($this->clients as $client) { + $resource = $client->getSocket(); + if (\is_resource($resource)) { + $set[] = $resource; + } + } + + return $set; + } + + private function acceptNew(): void + { + if (!\is_resource($this->listenSocket)) { + return; + } + // Always give the accept call enough room for the TLS/SSL handshake. + // select() has already told us a client is queued, so this won't sit + // idly — it bounds the handshake itself. + $handshakeTimeout = $this->timeout !== null && $this->timeout > 0 ? $this->timeout : 1.0; + $accepted = @stream_socket_accept($this->listenSocket, $handshakeTimeout); + if ($accepted === false || !\is_resource($accepted)) { + return; + } + stream_set_blocking($accepted, $this->blocking); + if ($this->timeout !== null) { + stream_set_timeout($accepted, (int) $this->timeout, (int) (($this->timeout - (int) $this->timeout) * 1_000_000)); + } + if ($this->crypto !== null) { + @stream_socket_enable_crypto($accepted, true, $this->crypto->forServer()); + } + $this->addClient(new ServerConnection(new StreamChannel($accepted))); + } + + /** + * @param resource $resource + * @param callable(\InitPHP\Socket\Interfaces\SocketServerInterface, \InitPHP\Socket\Interfaces\SocketConnectionInterface): void $callback + */ + private function serviceReadable($resource, callable $callback): void + { + $key = $this->findKeyByResource($resource); + if ($key === null) { + return; + } + $client = $this->clients[$key]; + if (!$client->isAlive()) { + $client->close(); + $this->evict($key); + + return; + } + $callback($this, $client); + } + + /** + * @param resource $resource + */ + private function findKeyByResource($resource): ?int + { + foreach ($this->clients as $key => $client) { + if ($client->getSocket() === $resource) { + return $key; + } + } + + return null; + } +} diff --git a/src/Server/SSL.php b/src/Server/SSL.php index e1b6503..fbb7e22 100644 --- a/src/Server/SSL.php +++ b/src/Server/SSL.php @@ -1,28 +1,15 @@ - * @copyright Copyright © 2022 InitPHP - * @license http://initphp.github.io/license.txt MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ declare(strict_types=1); namespace InitPHP\Socket\Server; -use InitPHP\Socket\Common\{BaseServer, StreamServerTrait}; -use InitPHP\Socket\Interfaces\SocketServerInterface; +use InitPHP\Socket\Enum\Transport; -class SSL extends BaseServer implements SocketServerInterface +final class SSL extends AbstractStreamServer { - - use StreamServerTrait; - - protected string $type = 'ssl'; - + public function __construct(string $host, int $port, ?float $timeout = null) + { + parent::__construct($host, $port, Transport::SSL, $timeout); + } } diff --git a/src/Server/ServerClient.php b/src/Server/ServerClient.php deleted file mode 100644 index e198539..0000000 --- a/src/Server/ServerClient.php +++ /dev/null @@ -1,195 +0,0 @@ - null, - ]; - - private $socket; - - public function __construct(array $credentials = []) - { - !empty($credentials) && self::$credentials = array_merge(self::$credentials, $credentials); - } - - public function __destruct() - { - $this->close(); - } - - public static function __setCallbacks(string $callback, array $arguments): void - { - if (!isset(self::$credentials['callbacks'])) { - self::$credentials['callbacks'] = []; - } - - self::$credentials['callbacks'][$callback] = $arguments; - } - - public static function __removeCallbacks(string $callback): void - { - if (isset(self::$credentials['callbacks'][$callback])) { - unset(self::$credentials['callbacks'][$callback]); - } - } - - public function __setSocket($socket): self - { - if (isset($this->socket)) { - throw new \Exception("Client cannot be changed"); - } - $this->socket = $socket; - socket_set_nonblock($this->socket); - - if (!empty(self::$credentials['callbacks'])) { - foreach (self::$credentials['callbacks'] as $callback => $arguments) { - foreach ($arguments as &$argument) { - $argument == '{socket}' && $argument = $this->socket; - } - call_user_func_array($callback, $arguments); - } - } - - echo "New client connected." . \PHP_EOL; - - return $this; - } - - /** - * @inheritDoc - */ - public function setId($id): self - { - if (!is_numeric($id) && !is_string($id)) { - throw new InvalidArgumentException("The Client ID can be string or numeric."); - } - $this->id = $id; - - return $this; - } - - /** - * @inheritDoc - */ - public function getId() - { - return $this->id ?? null; - } - - /** - * @inheritDoc - */ - public function getSocket() - { - return $this->socket ?? false; - } - - /** - * @inheritDoc - */ - public function push(string $message) - { - if (!isset($this->socket)) { - return false; - } - switch (self::$credentials['type']) { - case Socket::TCP: - return socket_write($this->socket, $message, strlen($message)); - case Socket::UDP: - return socket_sendto($this->socket, $message, strlen($message), 0, self::$credentials['host'], self::$credentials['port']); - case Socket::SSL: - case Socket::TLS: - return fwrite($this->socket, $message, strlen($message)); - default: - return false; - } - } - - /** - * @inheritDoc - */ - public function read(int $length = 1024, ?int $type = null) - { - switch (self::$credentials['type']) { - case Socket::TCP: - null === $type && $type = \PHP_BINARY_READ; - return socket_read($this->socket, $length, $type); - case Socket::UDP: - $content = null; - $name = self::$credentials['host']; - $port = self::$credentials['port']; - null === $type && $type = 0; - if (!socket_recvfrom($this->socket, $content, $length, $type, $name, $port)) { - return false; - } - return null === $content ? false : $content; - case Socket::SSL: - case Socket::TLS: - return fread($this->socket, $length); - default: - return false; - } - } - - /** - * @inheritDoc - */ - public function isDisconnected(): bool - { - try { - return !isset($this->socket) || $this->read(1024, \PHP_NORMAL_READ) === false; - } catch (\Throwable $e) { - return true; - } - } - - /** - * @inheritDoc - */ - public function close(): bool - { - if (!isset($this->socket)) { - return true; - } - - switch (self::$credentials['type']) { - case Socket::TCP: - case Socket::UDP: - socket_close($this->socket); - break; - case Socket::TLS: - case Socket::SSL: - fclose($this->socket); - break; - } - - unset($this->socket); - - echo "Client disconnected." . \PHP_EOL; - - return true; - } - -} diff --git a/src/Server/ServerConnection.php b/src/Server/ServerConnection.php new file mode 100644 index 0000000..9e8d3e6 --- /dev/null +++ b/src/Server/ServerConnection.php @@ -0,0 +1,59 @@ +id = $id; + + return $this; + } + + public function getId(): int|string|null + { + return $this->id; + } + + public function read(int $length = 1024): ?string + { + return $this->channel->read($length); + } + + public function write(string $data): ?int + { + return $this->channel->write($data); + } + + public function close(): bool + { + return $this->channel->close(); + } + + public function isAlive(): bool + { + return $this->channel->isAlive(); + } + + public function getSocket(): mixed + { + return $this->channel->getResource(); + } + + public function getChannel(): ChannelInterface + { + return $this->channel; + } +} diff --git a/src/Server/TCP.php b/src/Server/TCP.php index e6d2a05..35e733c 100644 --- a/src/Server/TCP.php +++ b/src/Server/TCP.php @@ -1,77 +1,198 @@ - * @copyright Copyright © 2022 InitPHP - * @license http://initphp.github.io/license.txt MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ declare(strict_types=1); namespace InitPHP\Socket\Server; -use InitPHP\Socket\Socket; -use InitPHP\Socket\Common\{ServerTrait, BaseServer}; -use InitPHP\Socket\Exception\{SocketException, SocketListenException}; -use \InitPHP\Socket\Interfaces\SocketServerInterface; +use InitPHP\Socket\Channel\TcpChannel; +use InitPHP\Socket\Enum\Domain; +use InitPHP\Socket\Exception\SocketConnectionException; +use InitPHP\Socket\Exception\SocketException; +use InitPHP\Socket\Exception\SocketListenException; +use Socket; -use const PHP_BINARY_READ; - -use function socket_listen; +use function getprotobyname; use function socket_accept; +use function socket_bind; use function socket_close; +use function socket_create; +use function socket_last_error; +use function socket_listen; +use function socket_select; +use function socket_set_nonblock; +use function socket_strerror; + +use const SOCK_STREAM; +use const SOCKET_EINTR; -class TCP extends BaseServer implements SocketServerInterface +final class TCP extends AbstractServer { + private ?Socket $listenSocket = null; - use ServerTrait; + private int $backlog = 8; - protected int $backlog = 3; + public function __construct( + string $host, + int $port, + private readonly Domain $domain = Domain::V4, + ) { + parent::__construct($host, $port); + } - public function connection(): self + public function backlog(int $backlog): self { - $this->socket = $this->createSocketSource('tcp', SOCK_STREAM, $this->domain); - $host = $this->getHost(); - $port = $this->getPort(); - $this->socketBind($this->socket, $host, $port); - if(socket_listen($this->socket, $this->backlog) === FALSE){ - throw new SocketListenException('Socket Listen Error : ' . $this->getLastError()); - } - if(($accept = socket_accept($this->socket)) === FALSE){ - throw new SocketException('Socket Accept Error : ' . $this->getLastError()); + if ($backlog < 1) { + throw new \InvalidArgumentException('backlog must be at least 1.'); } + $this->backlog = $backlog; - $this->clients[] = (new ServerClient([ - 'type' => Socket::TCP, - ]))->__setSocket($accept); + return $this; + } + + public function listen(): static + { + if ($this->listenSocket !== null) { + throw new SocketException('Server is already listening.'); + } + $proto = getprotobyname('tcp'); + $socket = @socket_create($this->domain->toAddressFamily(), SOCK_STREAM, $proto === false ? 0 : $proto); + if (!$socket instanceof Socket) { + throw new SocketException('socket_create failed: ' . socket_strerror(socket_last_error())); + } + if (@socket_bind($socket, $this->host, $this->port) === false) { + $err = socket_strerror(socket_last_error($socket)); + socket_close($socket); + throw new SocketException('socket_bind failed: ' . $err); + } + if (@socket_listen($socket, $this->backlog) === false) { + $err = socket_strerror(socket_last_error($socket)); + socket_close($socket); + throw new SocketListenException('socket_listen failed: ' . $err); + } + socket_set_nonblock($socket); + $this->listenSocket = $socket; return $this; } - public function disconnect(): bool + public function close(): bool { - if (!empty($this->clients)) { - foreach ($this->clients as $client) { - $client->close(); + foreach ($this->clients as $client) { + $client->close(); + } + $this->clients = []; + $this->clientIdMap = []; + if ($this->listenSocket !== null) { + @socket_close($this->listenSocket); + $this->listenSocket = null; + } + $this->running = false; + + return true; + } + + public function tick(callable $callback, float $waitSeconds = 0.0): int + { + if ($this->listenSocket === null) { + throw new SocketException('Server is not listening. Call listen() first.'); + } + [$sec, $usec] = self::splitSeconds($waitSeconds); + $read = $this->buildReadSet(); + $write = null; + $except = null; + $ready = @socket_select($read, $write, $except, $sec, $usec); + if ($ready === false) { + $errno = socket_last_error(); + if ($errno === SOCKET_EINTR) { + return 0; + } + throw new SocketException('socket_select failed: ' . socket_strerror($errno)); + } + if ($ready === 0) { + return 0; + } + $events = 0; + foreach ($read as $readableSocket) { + if ($readableSocket === $this->listenSocket) { + $this->acceptNew(); + ++$events; + continue; } + $this->serviceReadable($readableSocket, $callback); + ++$events; } - if(isset($this->socket)){ - socket_close($this->socket); + return $events; + } + + public function getSocket(): ?Socket + { + return $this->listenSocket; + } + + /** + * @return array + */ + private function buildReadSet(): array + { + $set = []; + if ($this->listenSocket !== null) { + $set[] = $this->listenSocket; + } + foreach ($this->clients as $client) { + $resource = $client->getSocket(); + if ($resource instanceof Socket) { + $set[] = $resource; + } } - return true; + return $set; } - public function backlog(int $backlog): self + private function acceptNew(): void { - $this->backlog = $backlog; - return $this; + if ($this->listenSocket === null) { + return; + } + $accepted = @socket_accept($this->listenSocket); + if (!$accepted instanceof Socket) { + $err = socket_last_error($this->listenSocket); + if ($err === 0 || $err === SOCKET_EINTR) { + return; + } + throw new SocketConnectionException('socket_accept failed: ' . socket_strerror($err)); + } + @socket_set_nonblock($accepted); + $this->addClient(new ServerConnection(new TcpChannel($accepted))); } + /** + * @param callable(\InitPHP\Socket\Interfaces\SocketServerInterface, \InitPHP\Socket\Interfaces\SocketConnectionInterface): void $callback + */ + private function serviceReadable(Socket $socket, callable $callback): void + { + $key = $this->findKeyBySocket($socket); + if ($key === null) { + return; + } + $client = $this->clients[$key]; + if (!$client->isAlive()) { + $client->close(); + $this->evict($key); + + return; + } + $callback($this, $client); + } + + private function findKeyBySocket(Socket $socket): ?int + { + foreach ($this->clients as $key => $client) { + if ($client->getSocket() === $socket) { + return $key; + } + } + + return null; + } } diff --git a/src/Server/TLS.php b/src/Server/TLS.php index bcd0fc9..87c350d 100644 --- a/src/Server/TLS.php +++ b/src/Server/TLS.php @@ -1,28 +1,15 @@ - * @copyright Copyright © 2022 InitPHP - * @license http://initphp.github.io/license.txt MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ declare(strict_types=1); namespace InitPHP\Socket\Server; -use \InitPHP\Socket\Common\{StreamServerTrait, BaseServer}; -use \InitPHP\Socket\Interfaces\SocketServerInterface; +use InitPHP\Socket\Enum\Transport; -class TLS extends BaseServer implements SocketServerInterface +final class TLS extends AbstractStreamServer { - - use StreamServerTrait; - - protected string $type = 'tls'; - + public function __construct(string $host, int $port, ?float $timeout = null) + { + parent::__construct($host, $port, Transport::TLS, $timeout); + } } diff --git a/src/Server/UDP.php b/src/Server/UDP.php index 2ee120f..6805bc6 100644 --- a/src/Server/UDP.php +++ b/src/Server/UDP.php @@ -1,62 +1,149 @@ - * @copyright Copyright © 2022 InitPHP - * @license http://initphp.github.io/license.txt MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ declare(strict_types=1); namespace InitPHP\Socket\Server; -use InitPHP\Socket\Common\{BaseServer, ServerTrait}; -use InitPHP\Socket\Interfaces\SocketServerInterface; +use InitPHP\Socket\Channel\UdpChannel; +use InitPHP\Socket\Enum\Domain; +use InitPHP\Socket\Exception\SocketException; +use Socket; -use InitPHP\Socket\Socket; +use function getprotobyname; +use function socket_bind; use function socket_close; +use function socket_create; +use function socket_last_error; +use function socket_recvfrom; +use function socket_select; +use function socket_set_nonblock; +use function socket_strerror; -class UDP extends BaseServer implements SocketServerInterface +use const SOCK_DGRAM; +use const SOCKET_EINTR; + +final class UDP extends AbstractServer { - use ServerTrait; + private ?Socket $listenSocket = null; + + /** @var array peer "host:port" → internal client key */ + private array $peerIndex = []; + + /** Default datagram read size. UDP packets cannot exceed 65507 bytes payload. */ + public const MAX_DATAGRAM = 65535; + + public function __construct( + string $host, + int $port, + private readonly Domain $domain = Domain::V4, + ) { + parent::__construct($host, $port); + } - public function connection(): self + public function listen(): static { - $socket = $this->createSocketSource('udp', SOCK_DGRAM, $this->domain); - $host = $this->getHost(); - $port = $this->getPort(); - $this->socketBind($socket, $host, $port); - $this->socket = $socket; - $this->host = $host; - $this->port = $port; - - $this->clients[] = (new ServerClient([ - 'type' => Socket::UDP, - 'host' => $this->host, - 'port' => $this->port, - ]))->__setSocket($socket); + if ($this->listenSocket !== null) { + throw new SocketException('Server is already listening.'); + } + $proto = getprotobyname('udp'); + $socket = @socket_create($this->domain->toAddressFamily(), SOCK_DGRAM, $proto === false ? 0 : $proto); + if (!$socket instanceof Socket) { + throw new SocketException('socket_create failed: ' . socket_strerror(socket_last_error())); + } + if (@socket_bind($socket, $this->host, $this->port) === false) { + $err = socket_strerror(socket_last_error($socket)); + socket_close($socket); + throw new SocketException('socket_bind failed: ' . $err); + } + socket_set_nonblock($socket); + $this->listenSocket = $socket; return $this; } - public function disconnect(): bool + public function close(): bool { - if (!empty($this->clients)) { - foreach ($this->clients as $client) { - $client->close(); + foreach ($this->clients as $client) { + $client->close(); + } + $this->clients = []; + $this->clientIdMap = []; + $this->peerIndex = []; + if ($this->listenSocket !== null) { + @socket_close($this->listenSocket); + $this->listenSocket = null; + } + $this->running = false; + + return true; + } + + public function tick(callable $callback, float $waitSeconds = 0.0): int + { + if ($this->listenSocket === null) { + throw new SocketException('Server is not listening. Call listen() first.'); + } + [$sec, $usec] = self::splitSeconds($waitSeconds); + $read = [$this->listenSocket]; + $write = null; + $except = null; + $ready = @socket_select($read, $write, $except, $sec, $usec); + if ($ready === false) { + $errno = socket_last_error(); + if ($errno === SOCKET_EINTR) { + return 0; } + throw new SocketException('socket_select failed: ' . socket_strerror($errno)); } + if ($ready === 0) { + return 0; + } + $peerHost = ''; + $peerPort = 0; + $buf = ''; + $bytes = @socket_recvfrom($this->listenSocket, $buf, self::MAX_DATAGRAM, 0, $peerHost, $peerPort); + if ($bytes === false || $bytes === 0) { + return 0; + } + $client = $this->resolveOrCreate($peerHost, $peerPort); + $channel = $client->getChannel(); + if ($channel instanceof UdpChannel) { + $channel->feed($buf); + } + $callback($this, $client); + + return 1; + } + + public function getSocket(): ?Socket + { + return $this->listenSocket; + } - if(!empty($this->socket)){ - socket_close($this->socket); + private function resolveOrCreate(string $peerHost, int $peerPort): ServerConnection + { + $peerKey = $peerHost . ':' . $peerPort; + if (isset($this->peerIndex[$peerKey])) { + $clientKey = $this->peerIndex[$peerKey]; + $existing = $this->clients[$clientKey] ?? null; + if ($existing instanceof ServerConnection) { + return $existing; + } } + $channel = new UdpChannel($this->listenSocket(), $peerHost, $peerPort); + $connection = new ServerConnection($channel); + $clientKey = $this->addClient($connection); + $this->peerIndex[$peerKey] = $clientKey; - return true; + return $connection; } + private function listenSocket(): Socket + { + if ($this->listenSocket === null) { + throw new SocketException('Server is not listening.'); + } + + return $this->listenSocket; + } } diff --git a/src/Socket.php b/src/Socket.php index 1f41637..da0c6f0 100644 --- a/src/Socket.php +++ b/src/Socket.php @@ -1,87 +1,74 @@ - * @copyright Copyright © 2022 InitPHP - * @license http://initphp.github.io/license.txt MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ declare(strict_types=1); namespace InitPHP\Socket; -use InitPHP\Socket\Exception\SocketInvalidArgumentException; -use InitPHP\Socket\Interfaces\{SocketClientInterface, SocketServerInterface}; +use InitPHP\Socket\Client\SSL as SslClient; +use InitPHP\Socket\Client\TCP as TcpClient; +use InitPHP\Socket\Client\TLS as TlsClient; +use InitPHP\Socket\Client\UDP as UdpClient; +use InitPHP\Socket\Enum\Domain; +use InitPHP\Socket\Enum\Transport; +use InitPHP\Socket\Interfaces\SocketClientInterface; +use InitPHP\Socket\Interfaces\SocketServerInterface; +use InitPHP\Socket\Server\SSL as SslServer; +use InitPHP\Socket\Server\TCP as TcpServer; +use InitPHP\Socket\Server\TLS as TlsServer; +use InitPHP\Socket\Server\UDP as UdpServer; -class Socket +/** + * Factory entry point for the package. + * + * Use {@see self::server()} and {@see self::client()} to obtain a transport + * implementation by enum case rather than constructing concrete classes + * directly. + */ +final class Socket { - - public const SSL = 1; - public const TCP = 2; - public const TLS = 3; - public const UDP = 4; + private function __construct() + { + } /** - * @param int $handler - * @param string $host - * @param int $port - * @param null|string|float $argument

This value is the value that will be sent as 3 parameters to the constructor method of the handler. - * SSL or TLS = (float) Defines the timeout period. - * UDP or TCP = (string) Defines the protocol family to be used by the socket. "v4", "v6" or "unix" - *

- * @return SocketServerInterface + * Create a server bound to $host:$port. + * + * @param Domain|null $domain Address family for TCP/UDP. Ignored for TLS/SSL. + * @param float|null $timeout Default socket timeout for TLS/SSL. Ignored for TCP/UDP. */ - public static function server(int $handler = self::TCP, string $host = '', int $port = 0, $argument = null): SocketServerInterface - { - if(empty($host) || empty($port)){ - throw new SocketInvalidArgumentException('Server: host and port must be specified.'); - } - switch ($handler) { - case self::SSL: - return new \InitPHP\Socket\Server\SSL($host, $port, $argument); - case self::TCP: - return new \InitPHP\Socket\Server\TCP($host, $port, $argument); - case self::TLS: - return new \InitPHP\Socket\Server\TLS($host, $port, $argument); - case self::UDP: - return new \InitPHP\Socket\Server\UDP($host, $port, $argument); - default: - throw new SocketInvalidArgumentException("\$handler can only be one of the constants \"Socket::SSL\", \"Socket::TCP\", \"Socket::TLS\" or \"Socket::UDP\" ."); - } + public static function server( + Transport $transport, + string $host, + int $port, + ?Domain $domain = null, + ?float $timeout = null, + ): SocketServerInterface { + return match ($transport) { + Transport::TCP => new TcpServer($host, $port, $domain ?? Domain::V4), + Transport::UDP => new UdpServer($host, $port, $domain ?? Domain::V4), + Transport::TLS => new TlsServer($host, $port, $timeout), + Transport::SSL => new SslServer($host, $port, $timeout), + }; } /** - * @param int $handler - * @param string $host - * @param int $port - * @param null|string|float $argument

This value is the value that will be sent as 3 parameters to the constructor method of the handler. - * SSL or TLS = (float) Defines the timeout period. - * UDP or TCP = (string) Defines the protocol family to be used by the socket. "v4", "v6" or "unix" - *

- * @return SocketClientInterface + * Create a client targeting $host:$port. + * + * @param Domain|null $domain Address family for TCP/UDP. Ignored for TLS/SSL. + * @param float|null $timeout Connect timeout for TLS/SSL. Ignored for TCP/UDP. */ - public static function client(int $handler = self::TCP, string $host = '', int $port = 0, $argument = null): SocketClientInterface - { - if(empty($host) || empty($port)){ - throw new SocketInvalidArgumentException('Client: host and port must be specified.'); - } - switch ($handler) { - case self::SSL: - return new \InitPHP\Socket\Client\SSL($host, $port, $argument); - case self::TCP: - return new \InitPHP\Socket\Client\TCP($host, $port, $argument); - case self::TLS: - return new \InitPHP\Socket\Client\TLS($host, $port, $argument); - case self::UDP: - return new \InitPHP\Socket\Client\UDP($host, $port, $argument); - default: - throw new SocketInvalidArgumentException("\$handler can only be one of the constants \"Socket::SSL\", \"Socket::TCP\", \"Socket::TLS\" or \"Socket::UDP\" ."); - } + public static function client( + Transport $transport, + string $host, + int $port, + ?Domain $domain = null, + ?float $timeout = null, + ): SocketClientInterface { + return match ($transport) { + Transport::TCP => new TcpClient($host, $port, $domain ?? Domain::V4), + Transport::UDP => new UdpClient($host, $port, $domain ?? Domain::V4), + Transport::TLS => new TlsClient($host, $port, $timeout), + Transport::SSL => new SslClient($host, $port, $timeout), + }; } - } diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php new file mode 100644 index 0000000..a26955f --- /dev/null +++ b/tests/Integration/IntegrationTestCase.php @@ -0,0 +1,78 @@ + 2048, + 'private_key_type' => \OPENSSL_KEYTYPE_RSA, + ]); + self::assertNotFalse($pkey, 'openssl_pkey_new failed'); + $csr = openssl_csr_new(['commonName' => $commonName], $pkey); + self::assertNotFalse($csr, 'openssl_csr_new failed'); + $x509 = openssl_csr_sign($csr, null, $pkey, 1); + self::assertNotFalse($x509, 'openssl_csr_sign failed'); + openssl_x509_export($x509, $certPem); + openssl_pkey_export($pkey, $keyPem); + + $path = tempnam(sys_get_temp_dir(), 'initphp-socket-tls-') . '.pem'; + file_put_contents($path, $certPem . $keyPem); + $this->registerCleanup(static fn (): bool => @unlink($path)); + + return $path; + } + + /** @var array */ + private array $cleanups = []; + + /** + * @param callable(): mixed $cleanup + */ + protected function registerCleanup(callable $cleanup): void + { + $this->cleanups[] = $cleanup; + } + + protected function tearDown(): void + { + foreach (array_reverse($this->cleanups) as $cleanup) { + $cleanup(); + } + $this->cleanups = []; + } +} diff --git a/tests/Integration/TcpEchoTest.php b/tests/Integration/TcpEchoTest.php new file mode 100644 index 0000000..d5b9ee0 --- /dev/null +++ b/tests/Integration/TcpEchoTest.php @@ -0,0 +1,97 @@ +findFreePort(); + + $server = new TcpServer('127.0.0.1', $port); + $server->listen(); + $this->registerCleanup($server->close(...)); + + $client = new TcpClient('127.0.0.1', $port); + $client->connect(); + $this->registerCleanup($client->disconnect(...)); + + // First tick: pick up the new connection from the listen backlog. + $server->tick(static fn () => null, 0.5); + self::assertCount(1, $server->getClients()); + + // Client → server payload. + self::assertSame(11, $client->write('hello-world')); + + // Second tick: server reads the inbound bytes and echoes them back. + $received = null; + $server->tick( + static function (SocketServerInterface $srv, SocketConnectionInterface $conn) use (&$received): void { + $received = $conn->read(1024); + $conn->write('echo:' . (string) $received); + }, + 0.5, + ); + + self::assertSame('hello-world', $received); + + // Briefly wait for the kernel to flush the echo back to the client side. + $reply = null; + for ($i = 0; $i < 20 && $reply === null; ++$i) { + $reply = $client->read(1024); + if ($reply === null) { + usleep(10_000); + } + } + self::assertSame('echo:hello-world', $reply); + } + + public function testBroadcastReachesEveryConnectedClient(): void + { + $port = $this->findFreePort(); + + $server = new TcpServer('127.0.0.1', $port); + $server->listen(); + $this->registerCleanup($server->close(...)); + + $clientA = new TcpClient('127.0.0.1', $port); + $clientB = new TcpClient('127.0.0.1', $port); + $clientA->connect(); + $clientB->connect(); + $this->registerCleanup($clientA->disconnect(...)); + $this->registerCleanup($clientB->disconnect(...)); + + // Accept both pending connections. + $server->tick(static fn () => null, 0.2); + $server->tick(static fn () => null, 0.2); + self::assertCount(2, $server->getClients()); + + self::assertTrue($server->broadcast('beacon')); + + // Drain both clients. + $readA = $this->awaitRead($clientA); + $readB = $this->awaitRead($clientB); + self::assertSame('beacon', $readA); + self::assertSame('beacon', $readB); + } + + private function awaitRead(TcpClient $client): ?string + { + for ($i = 0; $i < 50; ++$i) { + $chunk = $client->read(1024); + if ($chunk !== null) { + return $chunk; + } + usleep(10_000); + } + + return null; + } +} diff --git a/tests/Integration/TlsEchoTest.php b/tests/Integration/TlsEchoTest.php new file mode 100644 index 0000000..287c9d9 --- /dev/null +++ b/tests/Integration/TlsEchoTest.php @@ -0,0 +1,105 @@ +findFreePort(); + $certPath = $this->selfSignedCertPath(); + + $pid = pcntl_fork(); + self::assertNotSame(-1, $pid, 'pcntl_fork failed'); + + if ($pid === 0) { + $exitCode = $this->runServerChild($port, $certPath); + // Hard-exit so PHPUnit shutdown handlers don't run in the child. + exit($exitCode); + } + + try { + // Give the child a moment to bind before we connect. + usleep(150_000); + + $client = (new TlsClient('127.0.0.1', $port, 2.0)) + ->option('verify_peer', false) + ->option('verify_peer_name', false) + ->option('allow_self_signed', true); + $client->connect(); + + self::assertSame(9, $client->write('hello-tls')); + + $reply = $this->awaitRead($client); + $client->disconnect(); + + $status = 0; + pcntl_waitpid($pid, $status); + self::assertTrue(pcntl_wifexited($status), 'child did not exit cleanly'); + self::assertSame(0, pcntl_wexitstatus($status), 'server child reported failure'); + + self::assertSame('echo:hello-tls', $reply); + } finally { + if (posix_kill($pid, 0)) { + posix_kill($pid, \SIGTERM); + pcntl_waitpid($pid, $_status); + } + } + } + + private function runServerChild(int $port, string $certPath): int + { + try { + $server = (new TlsServer('127.0.0.1', $port, 2.0)) + ->option('local_cert', $certPath) + ->option('allow_self_signed', true) + ->option('verify_peer', false); + $server->listen(); + + $deadline = microtime(true) + 4.0; + $handled = false; + while (!$handled && microtime(true) < $deadline) { + $server->tick( + static function (SocketServerInterface $srv, SocketConnectionInterface $conn) use (&$handled): void { + $data = $conn->read(1024); + if ($data !== null) { + $conn->write('echo:' . $data); + $handled = true; + } + }, + 0.05, + ); + } + $server->close(); + + return $handled ? 0 : 10; + } catch (\Throwable $e) { + fwrite(\STDERR, 'tls server child error: ' . $e->getMessage() . "\n"); + + return 1; + } + } + + private function awaitRead(TlsClient $client): ?string + { + for ($i = 0; $i < 100; ++$i) { + $chunk = $client->read(1024); + if ($chunk !== null) { + return $chunk; + } + usleep(20_000); + } + + return null; + } +} diff --git a/tests/Integration/UdpEchoTest.php b/tests/Integration/UdpEchoTest.php new file mode 100644 index 0000000..4895cce --- /dev/null +++ b/tests/Integration/UdpEchoTest.php @@ -0,0 +1,81 @@ +findFreePort(\SOCK_DGRAM); + + $server = new UdpServer('127.0.0.1', $port); + $server->listen(); + $this->registerCleanup($server->close(...)); + + $client = new UdpClient('127.0.0.1', $port); + $client->connect(); + $this->registerCleanup($client->disconnect(...)); + + self::assertSame(5, $client->write('hello')); + + $received = null; + $server->tick( + static function (SocketServerInterface $srv, SocketConnectionInterface $conn) use (&$received): void { + $received = $conn->read(65535); + $conn->write('echo:' . (string) $received); + }, + 0.5, + ); + + self::assertSame('hello', $received); + self::assertCount(1, $server->getClients()); + + $reply = null; + for ($i = 0; $i < 20 && $reply === null; ++$i) { + $reply = $client->read(1024); + if ($reply === null) { + usleep(10_000); + } + } + self::assertSame('echo:hello', $reply); + } + + public function testServerMaintainsSeparateConnectionsPerPeer(): void + { + $port = $this->findFreePort(\SOCK_DGRAM); + $server = new UdpServer('127.0.0.1', $port); + $server->listen(); + $this->registerCleanup($server->close(...)); + + $clientA = new UdpClient('127.0.0.1', $port); + $clientB = new UdpClient('127.0.0.1', $port); + $clientA->connect(); + $clientB->connect(); + $this->registerCleanup($clientA->disconnect(...)); + $this->registerCleanup($clientB->disconnect(...)); + + $clientA->write('from-a'); + $clientB->write('from-b'); + + $messages = []; + $cb = static function (SocketServerInterface $srv, SocketConnectionInterface $conn) use (&$messages): void { + $payload = $conn->read(65535); + if ($payload !== null) { + $messages[] = $payload; + } + }; + $server->tick($cb, 0.5); + $server->tick($cb, 0.5); + + sort($messages); + self::assertSame(['from-a', 'from-b'], $messages); + self::assertCount(2, $server->getClients()); + } +} diff --git a/tests/Unit/Channel/UdpChannelTest.php b/tests/Unit/Channel/UdpChannelTest.php new file mode 100644 index 0000000..66361ac --- /dev/null +++ b/tests/Unit/Channel/UdpChannelTest.php @@ -0,0 +1,62 @@ +socket = $sock; + } + + protected function tearDown(): void + { + @socket_close($this->socket); + } + + public function testReadDrainsBufferIncrementally(): void + { + $channel = new UdpChannel($this->socket, '127.0.0.1', 9999); + $channel->feed('hello world'); + self::assertSame('hello', $channel->read(5)); + self::assertSame(' world', $channel->read(1024)); + self::assertNull($channel->read(1024)); + } + + public function testReadReturnsNullWhenBufferEmpty(): void + { + $channel = new UdpChannel($this->socket, '127.0.0.1', 9999); + self::assertNull($channel->read()); + } + + public function testCloseDropsBufferAndMarksDead(): void + { + $channel = new UdpChannel($this->socket, '127.0.0.1', 9999); + $channel->feed('payload'); + self::assertTrue($channel->isAlive()); + self::assertTrue($channel->close()); + self::assertFalse($channel->isAlive()); + self::assertNull($channel->read()); + self::assertNull($channel->getResource()); + } + + public function testPeerKey(): void + { + $channel = new UdpChannel($this->socket, '10.0.0.5', 1234); + self::assertSame('10.0.0.5', $channel->getPeerHost()); + self::assertSame(1234, $channel->getPeerPort()); + self::assertSame('10.0.0.5:1234', $channel->peerKey()); + } +} diff --git a/tests/Unit/Enum/CryptoMethodTest.php b/tests/Unit/Enum/CryptoMethodTest.php new file mode 100644 index 0000000..1a8ee3d --- /dev/null +++ b/tests/Unit/Enum/CryptoMethodTest.php @@ -0,0 +1,34 @@ +forClient()); + self::assertGreaterThanOrEqual(0, $case->forServer()); + } + } + + public function testFromNameIsCaseInsensitive(): void + { + self::assertSame(CryptoMethod::TLS, CryptoMethod::fromName('TLS')); + self::assertSame(CryptoMethod::TLSv1_2, CryptoMethod::fromName('tlsv1.2')); + } + + public function testFromNameRejectsUnknown(): void + { + $this->expectException(SocketInvalidArgumentException::class); + CryptoMethod::fromName('tlsv9'); + } +} diff --git a/tests/Unit/Enum/DomainTest.php b/tests/Unit/Enum/DomainTest.php new file mode 100644 index 0000000..cdb7523 --- /dev/null +++ b/tests/Unit/Enum/DomainTest.php @@ -0,0 +1,40 @@ +toAddressFamily()); + self::assertSame(\AF_INET6, Domain::V6->toAddressFamily()); + self::assertSame(\AF_UNIX, Domain::UNIX->toAddressFamily()); + } + + public function testFromNameAcceptsKnownStrings(): void + { + self::assertSame(Domain::V4, Domain::fromName('v4')); + self::assertSame(Domain::V6, Domain::fromName('V6')); + self::assertSame(Domain::UNIX, Domain::fromName('unix')); + } + + public function testFromNameDefaultsToV4WhenNullOrEmpty(): void + { + self::assertSame(Domain::V4, Domain::fromName(null)); + self::assertSame(Domain::V4, Domain::fromName('')); + } + + public function testFromNameRejectsUnknown(): void + { + $this->expectException(SocketInvalidArgumentException::class); + Domain::fromName('ipx'); + } +} diff --git a/tests/Unit/Enum/TransportTest.php b/tests/Unit/Enum/TransportTest.php new file mode 100644 index 0000000..ad3e47b --- /dev/null +++ b/tests/Unit/Enum/TransportTest.php @@ -0,0 +1,44 @@ +value); + self::assertSame('udp', Transport::UDP->value); + self::assertSame('tls', Transport::TLS->value); + self::assertSame('ssl', Transport::SSL->value); + } + + public function testIsStreamOnlyForTlsAndSsl(): void + { + self::assertTrue(Transport::TLS->isStream()); + self::assertTrue(Transport::SSL->isStream()); + self::assertFalse(Transport::TCP->isStream()); + self::assertFalse(Transport::UDP->isStream()); + } + + public function testIsDatagramOnlyForUdp(): void + { + self::assertTrue(Transport::UDP->isDatagram()); + self::assertFalse(Transport::TCP->isDatagram()); + self::assertFalse(Transport::TLS->isDatagram()); + self::assertFalse(Transport::SSL->isDatagram()); + } + + public function testSchemeMatchesEnumValue(): void + { + foreach (Transport::cases() as $case) { + self::assertSame($case->value, $case->scheme()); + } + } +} diff --git a/tests/Unit/Exception/HierarchyTest.php b/tests/Unit/Exception/HierarchyTest.php new file mode 100644 index 0000000..8365d6d --- /dev/null +++ b/tests/Unit/Exception/HierarchyTest.php @@ -0,0 +1,34 @@ +attach($this->makeConnection($aliceChannel)); + $bob = $server->attach($this->makeConnection($bobChannel)); + + self::assertTrue($server->broadcast('hello')); + + self::assertSame(['hello'], $this->writesOf($aliceChannel)); + self::assertSame(['hello'], $this->writesOf($bobChannel)); + } + + public function testBroadcastSkipsDeadClients(): void + { + $server = new TestableServer('127.0.0.1', 9000); + $server->attach($this->makeConnection($aliveChannel)); + $deadConnection = $this->makeConnection($deadChannel); + $server->attach($deadConnection); + $deadChannel->alive = false; + + self::assertTrue($server->broadcast('ping')); + + self::assertSame(['ping'], $this->writesOf($aliveChannel)); + self::assertSame([], $this->writesOf($deadChannel)); + } + + public function testRegisterAllowsTargetedBroadcast(): void + { + $server = new TestableServer('127.0.0.1', 9000); + $adminConnection = $this->makeConnection($adminChannel); + $guestConnection = $this->makeConnection($guestChannel); + $server->attach($adminConnection); + $server->attach($guestConnection); + + self::assertTrue($server->register('admin', $adminConnection)); + self::assertTrue($server->register('guest', $guestConnection)); + + $server->broadcast('only-admin', 'admin'); + $server->broadcast('mass-by-list', ['admin', 'guest']); + $server->broadcast('unknown-noop', 'ghost'); + + self::assertSame(['only-admin', 'mass-by-list'], $this->writesOf($adminChannel)); + self::assertSame(['mass-by-list'], $this->writesOf($guestChannel)); + self::assertSame('admin', $adminConnection->getId()); + } + + public function testRegisterReturnsFalseForUnknownConnection(): void + { + $server = new TestableServer('127.0.0.1', 9000); + $foreign = $this->makeConnection($_unused); + self::assertFalse($server->register('x', $foreign)); + } + + public function testWaitRejectsNegative(): void + { + $server = new TestableServer('127.0.0.1', 9000); + $this->expectException(SocketInvalidArgumentException::class); + $server->wait(-1.0); + } + + public function testGetClientsKeyedByIdWhenRegistered(): void + { + $server = new TestableServer('127.0.0.1', 9000); + $a = $this->makeConnection($_a); + $b = $this->makeConnection($_b); + $server->attach($a); + $server->attach($b); + $server->register('alice', $a); + + $clients = $server->getClients(); + self::assertArrayHasKey('alice', $clients); + self::assertSame($a, $clients['alice']); + // unregistered second client falls back to its internal numeric key + self::assertCount(2, $clients); + } + + /** + * @param-out FakeChannel $channel + */ + private function makeConnection(?ChannelInterface &$channel): SocketConnectionInterface + { + $channel = new FakeChannel(); + + return new ServerConnection($channel); + } + + /** + * @return array + */ + private function writesOf(ChannelInterface $channel): array + { + \assert($channel instanceof FakeChannel); + + return array_map(static fn (array $call): string => $call[0], $channel->writeCalls); + } +} + +/** + * Concrete subclass that exposes the protected addClient() so we can + * exercise broadcast/register/getClients without touching real sockets. + */ +final class TestableServer extends AbstractServer +{ + public function attach(SocketConnectionInterface $client): int + { + return $this->addClient($client); + } + + public function listen(): static + { + return $this; + } + + public function close(): bool + { + return true; + } + + public function tick(callable $callback, float $waitSeconds = 0.0): int + { + return 0; + } + + public function getSocket(): mixed + { + return null; + } +} diff --git a/tests/Unit/Server/ServerConnectionTest.php b/tests/Unit/Server/ServerConnectionTest.php new file mode 100644 index 0000000..f26e0eb --- /dev/null +++ b/tests/Unit/Server/ServerConnectionTest.php @@ -0,0 +1,104 @@ +readReturn = 'hello'; + $channel->writeReturn = 5; + + $connection = new ServerConnection($channel); + + self::assertSame('hello', $connection->read(128)); + self::assertSame([128, null], $channel->readCalls[0]); + + self::assertSame(5, $connection->write('hello')); + self::assertSame(['hello'], $channel->writeCalls[0]); + + self::assertTrue($connection->close()); + self::assertTrue($channel->closed); + } + + public function testIdGetterAndSetter(): void + { + $connection = new ServerConnection(new FakeChannel()); + self::assertNull($connection->getId()); + $connection->setId('admin'); + self::assertSame('admin', $connection->getId()); + $connection->setId(42); + self::assertSame(42, $connection->getId()); + } + + public function testExposesChannelAndUnderlyingResource(): void + { + $channel = new FakeChannel(); + $resource = (object) ['marker' => true]; + $channel->resource = $resource; + + $connection = new ServerConnection($channel); + + self::assertSame($channel, $connection->getChannel()); + self::assertSame($resource, $connection->getSocket()); + } +} + +final class FakeChannel implements ChannelInterface +{ + public mixed $resource = null; + + public ?string $readReturn = null; + + public ?int $writeReturn = null; + + public bool $alive = true; + + public bool $closed = false; + + /** @var array */ + public array $readCalls = []; + + /** @var array */ + public array $writeCalls = []; + + public function read(int $length = 1024, ?int $flag = null): ?string + { + $this->readCalls[] = [$length, $flag]; + + return $this->readReturn; + } + + public function write(string $data): ?int + { + $this->writeCalls[] = [$data]; + + return $this->writeReturn; + } + + public function close(): bool + { + $this->closed = true; + + return true; + } + + public function isAlive(): bool + { + return $this->alive && !$this->closed; + } + + public function getResource(): mixed + { + return $this->resource; + } +} diff --git a/tests/Unit/SocketFactoryTest.php b/tests/Unit/SocketFactoryTest.php new file mode 100644 index 0000000..a9f3d46 --- /dev/null +++ b/tests/Unit/SocketFactoryTest.php @@ -0,0 +1,52 @@ +expectException(SocketInvalidArgumentException::class); + Socket::server(Transport::TCP, '', 80); + } + + public function testPortOutOfRangeRejected(): void + { + $this->expectException(SocketInvalidArgumentException::class); + Socket::client(Transport::TCP, '127.0.0.1', 70000); + } +} From d40e43396018a49af4db966c89a15133a326da3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Sun, 24 May 2026 21:28:28 +0300 Subject: [PATCH 2/3] Add SocketInvalidArgumentException for StreamChannel and TCP, new tests --- CHANGELOG.md | 118 +++++++++++++++++++++ src/Channel/StreamChannel.php | 3 +- src/Interfaces/SocketServerInterface.php | 5 + src/Server/TCP.php | 3 +- tests/Integration/ServerLifecycleTest.php | 82 ++++++++++++++ tests/Unit/Server/TcpServerOptionsTest.php | 41 +++++++ 6 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 tests/Integration/ServerLifecycleTest.php create mode 100644 tests/Unit/Server/TcpServerOptionsTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f619509 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,118 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) +and this project adheres to [Semantic Versioning 2.0.0](https://semver.org/). + +## [Unreleased] + +## [2.0.0] — TBD + +### Highlights + +The 2.0 line is a clean break from 1.x — the previous server loop had +race conditions, lost inbound data through its liveness check, and +stored transport state on a `static` property that leaked between +instances. The new shape ships with explicit enums, per-transport +`Channel` strategies, a non-blocking `select`-driven loop and full +PHP 8.1+ typing. + +### Added + +- **PHP 8.1+ enums** — `Transport`, `Domain` and `CryptoMethod` replace + magic integer / string flags. +- **`ChannelInterface` + `TcpChannel` / `UdpChannel` / `StreamChannel`** — + per-transport I/O strategy. `ServerConnection` is now just identity + plus delegation. +- **`SocketExceptionInterface`** — marker implemented by every exception + in the package, so a single catch covers them all. +- **`tick(callable, float): int`** — single-iteration accept/dispatch + method on every server. Use it to embed the package in your own + event loop or to drive servers deterministically in tests. +- **`stop()` / `isRunning()`** — cooperative shutdown for the `live()` + loop. +- **`register(int|string, SocketConnectionInterface): bool`** — promoted + to `SocketServerInterface`; the 1.x package-private `clientRegister()` + is replaced by an interface method with a stable contract. +- **PHPUnit 10 test suite** — 36 unit + integration tests covering + enums, exception hierarchy, factory, channels, broadcast/register + logic, TCP echo, UDP per-peer routing, TLS handshake (forked). +- **CI pipeline** — GitHub Actions matrix across PHP 8.1, 8.2, 8.3 with + PHPStan level 8, PHP-CS-Fixer and Codecov upload. +- **`docs/` directory** — getting started, architecture, per-transport + server and client guides, cookbook (chat server, raw SMTP) and the + migration guide. + +### Changed + +- **Minimum PHP version** is now `^8.1` (was `>=7.4`). +- **`composer.json`** now requires `ext-openssl` in addition to + `ext-sockets`. +- **Server method renames** — `connection()` → `listen()`, + `disconnect()` → `close()`. `live()` signature now takes + `float $idleSeconds` instead of `int $usleep`. `wait()` is typed + `float $seconds`. +- **`SocketServerClientInterface` renamed to `SocketConnectionInterface`.** + `push()` is now `write()`. `isDisconnected()` is replaced by the + non-destructive `isAlive()`. +- **Exception hierarchy** — `SocketException` extends + `\RuntimeException`. `SocketConnectionException` and + `SocketListenException` now extend `SocketException` (previously + `\Exception`). `SocketInvalidArgumentException` still extends + `\InvalidArgumentException` and additionally implements + `SocketExceptionInterface`. + +### Removed + +- `Common/BaseClient`, `Common/BaseCommon`, `Common/BaseServer`, + `Common/ServerTrait`, `Common/StreamClientTrait`, + `Common/StreamServerTrait` — replaced by `Client/AbstractClient`, + `Client/AbstractStreamClient`, `Server/AbstractServer` and + `Server/AbstractStreamServer`. +- `Server/ServerClient` — replaced by `Server/ServerConnection`. +- `Interfaces/SocketServerClientInterface` — replaced by + `Interfaces/SocketConnectionInterface`. +- `Socket::TCP`, `Socket::UDP`, `Socket::TLS`, `Socket::SSL` integer + constants — replaced by the `Transport` enum. +- The mystery-typed `$argument` parameter on the factory and on every + constructor — replaced by explicit `?Domain $domain` / `?float $timeout` + named parameters. +- All `__setSocket()` / `__setCallbacks()` / `__removeCallbacks()` + magic-prefixed methods. +- The static `ServerClient::$credentials` array that leaked transport + state between server instances. +- `echo` calls inside `ServerClient` that wrote + `"New client connected."` / `"Client disconnected."` to STDOUT. + +### Fixed + +- **Server loop accepts more than one client.** The 1.x `connection()` + performed a blocking `socket_accept()` before `live()` was even + called, capping the server at a single connection per process. +- **TLS / SSL servers use the right accept call.** The 1.x loop called + `socket_accept()` on a stream resource (always wrong for TLS / SSL) + and never accepted the second connection. +- **Liveness checks no longer consume data.** The 1.x + `isDisconnected()` read a line off every client every iteration + and discarded it, so application-level reads never saw any data. +- **Client id map survives disconnects.** The 1.x `clientMap` stored + raw array indices that became dangling after `unset()` on a + disconnected client. The new `clientIdMap` uses monotonic keys and + cleans up on eviction. +- **UDP server demultiplexes peers correctly.** The 1.x server + registered the listening socket itself as a "client" and broadcast + back to its own host/port. +- **TLS handshake has enough time.** The 1.x server left the listening + stream non-blocking and called `stream_socket_accept(..., 0.0)`, + which prevented the handshake from completing under load. The new + loop keeps the listening stream blocking and drives readiness via + `stream_select`, giving the handshake the full timeout. + +### Migration + +See [`docs/migration-1.x-to-2.x.md`](./docs/migration-1.x-to-2.x.md) for +a step-by-step upgrade guide. + +[Unreleased]: https://github.com/InitPHP/Socket/compare/v2.0.0...HEAD +[2.0.0]: https://github.com/InitPHP/Socket/releases/tag/v2.0.0 diff --git a/src/Channel/StreamChannel.php b/src/Channel/StreamChannel.php index 6b5ca39..24628f8 100644 --- a/src/Channel/StreamChannel.php +++ b/src/Channel/StreamChannel.php @@ -4,6 +4,7 @@ namespace InitPHP\Socket\Channel; +use InitPHP\Socket\Exception\SocketInvalidArgumentException; use InitPHP\Socket\Interfaces\ChannelInterface; use function fclose; @@ -22,7 +23,7 @@ final class StreamChannel implements ChannelInterface public function __construct($stream) { if (!\is_resource($stream)) { - throw new \InvalidArgumentException('StreamChannel expects a stream resource.'); + throw new SocketInvalidArgumentException('StreamChannel expects a stream resource.'); } $this->stream = $stream; } diff --git a/src/Interfaces/SocketServerInterface.php b/src/Interfaces/SocketServerInterface.php index 6b714a0..c04a0c4 100644 --- a/src/Interfaces/SocketServerInterface.php +++ b/src/Interfaces/SocketServerInterface.php @@ -81,6 +81,11 @@ public function tick(callable $callback, float $waitSeconds = 0.0): int; */ public function stop(): void; + /** + * Returns true while the {@see self::live()} loop is running. + */ + public function isRunning(): bool; + /** * Sleep for $seconds (supports sub-second precision). */ diff --git a/src/Server/TCP.php b/src/Server/TCP.php index 35e733c..8bfad3c 100644 --- a/src/Server/TCP.php +++ b/src/Server/TCP.php @@ -8,6 +8,7 @@ use InitPHP\Socket\Enum\Domain; use InitPHP\Socket\Exception\SocketConnectionException; use InitPHP\Socket\Exception\SocketException; +use InitPHP\Socket\Exception\SocketInvalidArgumentException; use InitPHP\Socket\Exception\SocketListenException; use Socket; @@ -42,7 +43,7 @@ public function __construct( public function backlog(int $backlog): self { if ($backlog < 1) { - throw new \InvalidArgumentException('backlog must be at least 1.'); + throw new SocketInvalidArgumentException('backlog must be at least 1.'); } $this->backlog = $backlog; diff --git a/tests/Integration/ServerLifecycleTest.php b/tests/Integration/ServerLifecycleTest.php new file mode 100644 index 0000000..b98de0a --- /dev/null +++ b/tests/Integration/ServerLifecycleTest.php @@ -0,0 +1,82 @@ +findFreePort(); + $server = new TcpServer('127.0.0.1', $port); + $server->listen(); + $this->registerCleanup($server->close(...)); + + $this->expectException(SocketException::class); + $server->listen(); + } + + public function testCloseAfterListenIsIdempotent(): void + { + $port = $this->findFreePort(); + $server = new TcpServer('127.0.0.1', $port); + $server->listen(); + + self::assertTrue($server->close()); + self::assertTrue($server->close()); + self::assertNull($server->getSocket()); + } + + public function testRelistenAfterCloseSucceeds(): void + { + $portA = $this->findFreePort(); + $server = new TcpServer('127.0.0.1', $portA); + $server->listen(); + $server->close(); + + // After close(), the server can be configured for a new port and re-listened. + $portB = $this->findFreePort(); + $rebornServer = new TcpServer('127.0.0.1', $portB); + $rebornServer->listen(); + $this->registerCleanup($rebornServer->close(...)); + + self::assertNotNull($rebornServer->getSocket()); + } + + public function testTickBeforeListenThrows(): void + { + $server = new TcpServer('127.0.0.1', 9000); + + $this->expectException(SocketException::class); + $server->tick(static fn () => null, 0.0); + } + + public function testStopExitsLiveLoopOnNextTick(): void + { + $port = $this->findFreePort(); + $server = new TcpServer('127.0.0.1', $port); + $server->listen(); + $this->registerCleanup($server->close(...)); + + $iterations = 0; + $callback = static function () use ($server, &$iterations): void { + ++$iterations; + if ($iterations >= 1) { + $server->stop(); + } + }; + + // No clients ever connect, so tick() will idle out within idleSeconds. + // We rely on stop() being called externally via a tiny shim that + // hooks into a no-op activity: drive tick() ourselves a few times. + $server->stop(); + self::assertFalse($server->isRunning()); + // After stop() the live loop should return immediately. + $server->live($callback, 0.01); + self::assertSame(0, $iterations, 'live() should not enter the loop after stop()'); + } +} diff --git a/tests/Unit/Server/TcpServerOptionsTest.php b/tests/Unit/Server/TcpServerOptionsTest.php new file mode 100644 index 0000000..cd21b2d --- /dev/null +++ b/tests/Unit/Server/TcpServerOptionsTest.php @@ -0,0 +1,41 @@ +backlog(16)); + } + + public function testBacklogRejectsZero(): void + { + $this->expectException(SocketInvalidArgumentException::class); + (new TcpServer('127.0.0.1', 9000))->backlog(0); + } + + public function testBacklogRejectsNegative(): void + { + $this->expectException(SocketInvalidArgumentException::class); + (new TcpServer('127.0.0.1', 9000))->backlog(-1); + } + + public function testCloseIsIdempotentWhenNeverListened(): void + { + $server = new TcpServer('127.0.0.1', 9000); + self::assertTrue($server->close()); + self::assertTrue($server->close()); + self::assertNull($server->getSocket()); + self::assertFalse($server->isRunning()); + } +} From 87c4fa4735421295d365ce7d6ca037ac00f5da6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20=C5=9Eafak?= Date: Sun, 24 May 2026 21:45:09 +0300 Subject: [PATCH 3/3] Add tests for TCP and UDP client and server functionalities --- tests/Integration/ConnectionErrorsTest.php | 81 +++++++++++++ tests/Integration/ServerLifecycleTest.php | 41 ++++--- tests/Integration/StreamClientIoTest.php | 106 ++++++++++++++++++ tests/Integration/TcpDisconnectTest.php | 74 ++++++++++++ tests/Integration/TcpServerCloseTest.php | 48 ++++++++ tests/Integration/TlsEchoTest.php | 98 ++++++++-------- tests/Integration/UdpClientReuseTest.php | 70 ++++++++++++ tests/Integration/UdpServerCloseTest.php | 75 +++++++++++++ tests/Unit/Channel/StreamChannelTest.php | 69 ++++++++++++ tests/Unit/Channel/TcpChannelTest.php | 93 +++++++++++++++ tests/Unit/Client/AbstractClientTest.php | 73 ++++++++++++ .../Unit/Client/AbstractStreamClientTest.php | 59 ++++++++++ .../Server/AbstractServerBroadcastTest.php | 45 ++++++++ .../Unit/Server/AbstractStreamServerTest.php | 51 +++++++++ tests/Unit/Server/UdpServerOptionsTest.php | 37 ++++++ 15 files changed, 953 insertions(+), 67 deletions(-) create mode 100644 tests/Integration/ConnectionErrorsTest.php create mode 100644 tests/Integration/StreamClientIoTest.php create mode 100644 tests/Integration/TcpDisconnectTest.php create mode 100644 tests/Integration/TcpServerCloseTest.php create mode 100644 tests/Integration/UdpClientReuseTest.php create mode 100644 tests/Integration/UdpServerCloseTest.php create mode 100644 tests/Unit/Channel/StreamChannelTest.php create mode 100644 tests/Unit/Channel/TcpChannelTest.php create mode 100644 tests/Unit/Client/AbstractClientTest.php create mode 100644 tests/Unit/Client/AbstractStreamClientTest.php create mode 100644 tests/Unit/Server/AbstractStreamServerTest.php create mode 100644 tests/Unit/Server/UdpServerOptionsTest.php diff --git a/tests/Integration/ConnectionErrorsTest.php b/tests/Integration/ConnectionErrorsTest.php new file mode 100644 index 0000000..e9cf46c --- /dev/null +++ b/tests/Integration/ConnectionErrorsTest.php @@ -0,0 +1,81 @@ +findFreePort(); + + $client = new TcpClient('127.0.0.1', $port); + $this->expectException(SocketConnectionException::class); + $client->connect(); + } + + public function testTcpServerListenFailsOnPortConflict(): void + { + $port = $this->findFreePort(); + + $a = new TcpServer('127.0.0.1', $port); + $a->listen(); + $this->registerCleanup($a->close(...)); + + $b = new TcpServer('127.0.0.1', $port); + $this->expectException(SocketException::class); + $b->listen(); + } + + public function testUdpServerListenFailsOnPortConflict(): void + { + $port = $this->findFreePort(\SOCK_DGRAM); + + $a = new UdpServer('127.0.0.1', $port); + $a->listen(); + $this->registerCleanup($a->close(...)); + + $b = new UdpServer('127.0.0.1', $port); + $this->expectException(SocketException::class); + $b->listen(); + } + + public function testTcpClientWriteReadAfterDisconnectReturnsNull(): void + { + $port = $this->findFreePort(); + $server = new TcpServer('127.0.0.1', $port); + $server->listen(); + $this->registerCleanup($server->close(...)); + + $client = new TcpClient('127.0.0.1', $port); + $client->connect(); + $client->disconnect(); + + self::assertNull($client->write('x')); + self::assertNull($client->read(64)); + self::assertNull($client->getSocket()); + } + + public function testTcpServerConnectTwiceThrows(): void + { + $port = $this->findFreePort(); + $server = new TcpServer('127.0.0.1', $port); + $server->listen(); + $this->registerCleanup($server->close(...)); + + $client = new TcpClient('127.0.0.1', $port); + $client->connect(); + $this->registerCleanup($client->disconnect(...)); + + $this->expectException(SocketException::class); + $client->connect(); + } +} diff --git a/tests/Integration/ServerLifecycleTest.php b/tests/Integration/ServerLifecycleTest.php index b98de0a..7feac41 100644 --- a/tests/Integration/ServerLifecycleTest.php +++ b/tests/Integration/ServerLifecycleTest.php @@ -4,7 +4,10 @@ namespace InitPHP\Socket\Tests\Integration; +use InitPHP\Socket\Client\TCP as TcpClient; use InitPHP\Socket\Exception\SocketException; +use InitPHP\Socket\Interfaces\SocketConnectionInterface; +use InitPHP\Socket\Interfaces\SocketServerInterface; use InitPHP\Socket\Server\TCP as TcpServer; final class ServerLifecycleTest extends IntegrationTestCase @@ -55,28 +58,34 @@ public function testTickBeforeListenThrows(): void $server->tick(static fn () => null, 0.0); } - public function testStopExitsLiveLoopOnNextTick(): void + public function testStopFromInsideCallbackExitsLiveLoop(): void { $port = $this->findFreePort(); $server = new TcpServer('127.0.0.1', $port); $server->listen(); $this->registerCleanup($server->close(...)); - $iterations = 0; - $callback = static function () use ($server, &$iterations): void { - ++$iterations; - if ($iterations >= 1) { - $server->stop(); - } - }; - - // No clients ever connect, so tick() will idle out within idleSeconds. - // We rely on stop() being called externally via a tiny shim that - // hooks into a no-op activity: drive tick() ourselves a few times. - $server->stop(); + $client = new TcpClient('127.0.0.1', $port); + $client->connect(); + $this->registerCleanup($client->disconnect(...)); + + // Bring the client into the server's accept queue, then feed a byte + // so the next live() iteration actually fires the callback. + $server->tick(static fn () => null, 0.2); + self::assertCount(1, $server->getClients()); + self::assertSame(4, $client->write('stop')); + + $invocations = 0; + $server->live( + static function (SocketServerInterface $srv, SocketConnectionInterface $conn) use (&$invocations): void { + ++$invocations; + $conn->read(1024); + $srv->stop(); + }, + 0.05, + ); + + self::assertSame(1, $invocations); self::assertFalse($server->isRunning()); - // After stop() the live loop should return immediately. - $server->live($callback, 0.01); - self::assertSame(0, $iterations, 'live() should not enter the loop after stop()'); } } diff --git a/tests/Integration/StreamClientIoTest.php b/tests/Integration/StreamClientIoTest.php new file mode 100644 index 0000000..0a0e6f0 --- /dev/null +++ b/tests/Integration/StreamClientIoTest.php @@ -0,0 +1,106 @@ +findFreePort(); + $errNo = 0; + $errStr = ''; + $server = stream_socket_server("tcp://127.0.0.1:{$port}", $errNo, $errStr); + self::assertNotFalse($server, "stream_socket_server failed: {$errStr}"); + $this->registerCleanup(static fn () => @fclose($server)); + + $client = new PlainStreamClient('127.0.0.1', $port, 2.0); + $client->option('verify_peer', false) + ->timeout(1.5) + ->blocking(false); + $client->connect(); + $this->registerCleanup($client->disconnect(...)); + + $peer = stream_socket_accept($server, 1.0); + self::assertNotFalse($peer); + $this->registerCleanup(static fn () => @fclose($peer)); + + self::assertSame(5, $client->write('hello')); + // Allow the kernel to deliver. + usleep(20_000); + self::assertSame('hello', fread($peer, 1024)); + + fwrite($peer, 'world'); + $reply = null; + for ($i = 0; $i < 30 && $reply === null; ++$i) { + $reply = $client->read(1024); + if ($reply === null) { + usleep(10_000); + } + } + self::assertSame('world', $reply); + + // toggleable after connect — exercises stream_set_blocking + stream_set_timeout branches. + $client->blocking(true); + $client->timeout(0.5); + } + + public function testCryptoCanBeDisabledOnPlainStream(): void + { + $port = $this->findFreePort(); + $errNo = 0; + $errStr = ''; + $server = stream_socket_server("tcp://127.0.0.1:{$port}", $errNo, $errStr); + self::assertNotFalse($server); + $this->registerCleanup(static fn () => @fclose($server)); + + $client = new PlainStreamClient('127.0.0.1', $port); + $client->connect(); + $this->registerCleanup($client->disconnect(...)); + + // Disabling crypto on a non-encrypted stream is a no-op and must not throw. + self::assertSame($client, $client->crypto(null)); + } + + public function testConnectTwiceThrows(): void + { + $port = $this->findFreePort(); + $errNo = 0; + $errStr = ''; + $server = stream_socket_server("tcp://127.0.0.1:{$port}", $errNo, $errStr); + self::assertNotFalse($server); + $this->registerCleanup(static fn () => @fclose($server)); + + $client = new PlainStreamClient('127.0.0.1', $port); + $client->connect(); + $this->registerCleanup($client->disconnect(...)); + + $this->expectException(SocketException::class); + $client->connect(); + } +} + +/** + * Test-only subclass that drives the abstract stream client over a plain + * `tcp://` scheme. Lets us cover the abstract's I/O paths in the same + * process without the cost of a TLS handshake. + */ +final class PlainStreamClient extends AbstractStreamClient +{ + public function __construct(string $host, int $port, ?float $timeout = null) + { + parent::__construct($host, $port, Transport::TCP, $timeout); + } +} diff --git a/tests/Integration/TcpDisconnectTest.php b/tests/Integration/TcpDisconnectTest.php new file mode 100644 index 0000000..62a1190 --- /dev/null +++ b/tests/Integration/TcpDisconnectTest.php @@ -0,0 +1,74 @@ +findFreePort(); + $server = new TcpServer('127.0.0.1', $port); + $server->listen(); + $this->registerCleanup($server->close(...)); + + $client = new TcpClient('127.0.0.1', $port); + $client->connect(); + + // Accept the client. + $server->tick(static fn () => null, 0.2); + self::assertCount(1, $server->getClients()); + + // Client drops the connection. + $client->disconnect(); + + // The next tick should detect EOF on the dead socket and evict it. + // We may need a couple of iterations for the kernel to surface the close. + $eviction = false; + for ($i = 0; $i < 20 && !$eviction; ++$i) { + $server->tick(static fn () => null, 0.05); + if (\count($server->getClients()) === 0) { + $eviction = true; + } + } + self::assertTrue($eviction, 'server did not evict the disconnected client'); + } + + public function testBroadcastSkipsDeadClient(): void + { + $port = $this->findFreePort(); + $server = new TcpServer('127.0.0.1', $port); + $server->listen(); + $this->registerCleanup($server->close(...)); + + $alive = new TcpClient('127.0.0.1', $port); + $alive->connect(); + $this->registerCleanup($alive->disconnect(...)); + + $deadOnArrival = new TcpClient('127.0.0.1', $port); + $deadOnArrival->connect(); + $deadOnArrival->disconnect(); + + // Accept both. + $server->tick(static fn () => null, 0.2); + $server->tick(static fn () => null, 0.2); + self::assertCount(2, $server->getClients()); + + // Broadcast — the dead client write call should silently no-op and + // not raise; the alive client should still receive the message. + self::assertTrue($server->broadcast('beacon')); + + $reply = null; + for ($i = 0; $i < 30 && $reply === null; ++$i) { + $reply = $alive->read(1024); + if ($reply === null) { + usleep(10_000); + } + } + self::assertSame('beacon', $reply); + } +} diff --git a/tests/Integration/TcpServerCloseTest.php b/tests/Integration/TcpServerCloseTest.php new file mode 100644 index 0000000..523fe59 --- /dev/null +++ b/tests/Integration/TcpServerCloseTest.php @@ -0,0 +1,48 @@ +findFreePort(); + $server = new TcpServer('127.0.0.1', $port); + $server->listen(); + + $clients = []; + for ($i = 0; $i < 3; ++$i) { + $c = new TcpClient('127.0.0.1', $port); + $c->connect(); + $clients[] = $c; + $this->registerCleanup($c->disconnect(...)); + } + // Accept all of them. + for ($i = 0; $i < 3; ++$i) { + $server->tick(static fn () => null, 0.1); + } + self::assertCount(3, $server->getClients()); + + self::assertTrue($server->close()); + self::assertSame([], $server->getClients()); + self::assertNull($server->getSocket()); + } + + public function testCustomBacklogIsApplied(): void + { + $port = $this->findFreePort(); + $server = new TcpServer('127.0.0.1', $port); + self::assertSame($server, $server->backlog(2)); + $server->listen(); + $this->registerCleanup($server->close(...)); + + // We don't introspect the OS backlog directly; we just exercise the + // chained setter path and confirm listen() still works afterwards. + self::assertNotNull($server->getSocket()); + } +} diff --git a/tests/Integration/TlsEchoTest.php b/tests/Integration/TlsEchoTest.php index 287c9d9..ff10b7d 100644 --- a/tests/Integration/TlsEchoTest.php +++ b/tests/Integration/TlsEchoTest.php @@ -19,36 +19,48 @@ public function testEncryptedRoundTripWithSelfSignedCertificate(): void $port = $this->findFreePort(); $certPath = $this->selfSignedCertPath(); + // The TLS server must run in the parent process so its code paths + // show up in our coverage report. We fork the *client* into a + // child process instead — its job is to drive the handshake and + // exchange one message. $pid = pcntl_fork(); self::assertNotSame(-1, $pid, 'pcntl_fork failed'); if ($pid === 0) { - $exitCode = $this->runServerChild($port, $certPath); + $exitCode = $this->runClientChild($port); // Hard-exit so PHPUnit shutdown handlers don't run in the child. exit($exitCode); } try { - // Give the child a moment to bind before we connect. - usleep(150_000); - - $client = (new TlsClient('127.0.0.1', $port, 2.0)) - ->option('verify_peer', false) - ->option('verify_peer_name', false) - ->option('allow_self_signed', true); - $client->connect(); - - self::assertSame(9, $client->write('hello-tls')); + $server = (new TlsServer('127.0.0.1', $port, 2.0)) + ->option('local_cert', $certPath) + ->option('allow_self_signed', true) + ->option('verify_peer', false); + $server->listen(); - $reply = $this->awaitRead($client); - $client->disconnect(); + $received = null; + $deadline = microtime(true) + 5.0; + while ($received === null && microtime(true) < $deadline) { + $server->tick( + static function (SocketServerInterface $srv, SocketConnectionInterface $conn) use (&$received): void { + $payload = $conn->read(1024); + if ($payload !== null) { + $received = $payload; + $conn->write('echo:' . $payload); + } + }, + 0.1, + ); + } + $server->close(); $status = 0; pcntl_waitpid($pid, $status); - self::assertTrue(pcntl_wifexited($status), 'child did not exit cleanly'); - self::assertSame(0, pcntl_wexitstatus($status), 'server child reported failure'); + self::assertTrue(pcntl_wifexited($status), 'client child did not exit cleanly'); + self::assertSame(0, pcntl_wexitstatus($status), 'client child reported failure'); - self::assertSame('echo:hello-tls', $reply); + self::assertSame('hello-tls', $received); } finally { if (posix_kill($pid, 0)) { posix_kill($pid, \SIGTERM); @@ -57,49 +69,33 @@ public function testEncryptedRoundTripWithSelfSignedCertificate(): void } } - private function runServerChild(int $port, string $certPath): int + private function runClientChild(int $port): int { try { - $server = (new TlsServer('127.0.0.1', $port, 2.0)) - ->option('local_cert', $certPath) - ->option('allow_self_signed', true) - ->option('verify_peer', false); - $server->listen(); + // Give the parent a beat to bind before we connect. + usleep(150_000); - $deadline = microtime(true) + 4.0; - $handled = false; - while (!$handled && microtime(true) < $deadline) { - $server->tick( - static function (SocketServerInterface $srv, SocketConnectionInterface $conn) use (&$handled): void { - $data = $conn->read(1024); - if ($data !== null) { - $conn->write('echo:' . $data); - $handled = true; - } - }, - 0.05, - ); + $client = (new TlsClient('127.0.0.1', $port, 2.0)) + ->option('verify_peer', false) + ->option('verify_peer_name', false) + ->option('allow_self_signed', true); + $client->connect(); + $client->write('hello-tls'); + + $reply = null; + for ($i = 0; $i < 200 && $reply === null; ++$i) { + $reply = $client->read(1024); + if ($reply === null) { + usleep(20_000); + } } - $server->close(); + $client->disconnect(); - return $handled ? 0 : 10; + return $reply === 'echo:hello-tls' ? 0 : 11; } catch (\Throwable $e) { - fwrite(\STDERR, 'tls server child error: ' . $e->getMessage() . "\n"); + fwrite(\STDERR, 'tls client child error: ' . $e->getMessage() . "\n"); return 1; } } - - private function awaitRead(TlsClient $client): ?string - { - for ($i = 0; $i < 100; ++$i) { - $chunk = $client->read(1024); - if ($chunk !== null) { - return $chunk; - } - usleep(20_000); - } - - return null; - } } diff --git a/tests/Integration/UdpClientReuseTest.php b/tests/Integration/UdpClientReuseTest.php new file mode 100644 index 0000000..91c74e9 --- /dev/null +++ b/tests/Integration/UdpClientReuseTest.php @@ -0,0 +1,70 @@ +findFreePort(\SOCK_DGRAM); + $server = new UdpServer('127.0.0.1', $port); + $server->listen(); + $this->registerCleanup($server->close(...)); + + $client = new UdpClient('127.0.0.1', $port); + $client->connect(); + $this->registerCleanup($client->disconnect(...)); + + $this->expectException(SocketException::class); + $client->connect(); + } + + public function testGetSocketReturnsLiveResourceAfterConnect(): void + { + $port = $this->findFreePort(\SOCK_DGRAM); + $server = new UdpServer('127.0.0.1', $port); + $server->listen(); + $this->registerCleanup($server->close(...)); + + $client = new UdpClient('127.0.0.1', $port); + $client->connect(); + $this->registerCleanup($client->disconnect(...)); + + self::assertInstanceOf(Socket::class, $client->getSocket()); + } + + public function testServerReusesConnectionForReturningPeer(): void + { + $port = $this->findFreePort(\SOCK_DGRAM); + $server = new UdpServer('127.0.0.1', $port); + $server->listen(); + $this->registerCleanup($server->close(...)); + + $client = new UdpClient('127.0.0.1', $port); + $client->connect(); + $this->registerCleanup($client->disconnect(...)); + + $messages = []; + $cb = static function (SocketServerInterface $srv, SocketConnectionInterface $conn) use (&$messages): void { + $messages[] = $conn->read(65535); + }; + + $client->write('one'); + $server->tick($cb, 0.2); + $client->write('two'); + $server->tick($cb, 0.2); + + // The same peer must produce one client entry, not two. + self::assertCount(1, $server->getClients()); + self::assertSame(['one', 'two'], $messages); + } +} diff --git a/tests/Integration/UdpServerCloseTest.php b/tests/Integration/UdpServerCloseTest.php new file mode 100644 index 0000000..fc9b153 --- /dev/null +++ b/tests/Integration/UdpServerCloseTest.php @@ -0,0 +1,75 @@ +findFreePort(\SOCK_DGRAM); + $server = new UdpServer('127.0.0.1', $port); + $server->listen(); + + $client = new UdpClient('127.0.0.1', $port); + $client->connect(); + $this->registerCleanup($client->disconnect(...)); + + $client->write('greet'); + $server->tick(static function () { + }, 0.3); + self::assertCount(1, $server->getClients()); + + self::assertTrue($server->close()); + self::assertSame([], $server->getClients()); + self::assertNull($server->getSocket()); + } + + public function testBroadcastReachesEveryKnownPeer(): void + { + $port = $this->findFreePort(\SOCK_DGRAM); + $server = new UdpServer('127.0.0.1', $port); + $server->listen(); + $this->registerCleanup($server->close(...)); + + $alice = new UdpClient('127.0.0.1', $port); + $bob = new UdpClient('127.0.0.1', $port); + $alice->connect(); + $bob->connect(); + $this->registerCleanup($alice->disconnect(...)); + $this->registerCleanup($bob->disconnect(...)); + + $alice->write('hi'); + $bob->write('hi'); + + $cb = static function (SocketServerInterface $srv, SocketConnectionInterface $conn): void { + $conn->read(65535); + }; + $server->tick($cb, 0.3); + $server->tick($cb, 0.3); + self::assertCount(2, $server->getClients()); + + self::assertTrue($server->broadcast('announce')); + + $heard = 0; + for ($i = 0; $i < 40; ++$i) { + if ($alice->read(1024) !== null) { + ++$heard; + } + if ($bob->read(1024) !== null) { + ++$heard; + } + if ($heard >= 2) { + break; + } + usleep(10_000); + } + self::assertSame(2, $heard); + } +} diff --git a/tests/Unit/Channel/StreamChannelTest.php b/tests/Unit/Channel/StreamChannelTest.php new file mode 100644 index 0000000..cdc6401 --- /dev/null +++ b/tests/Unit/Channel/StreamChannelTest.php @@ -0,0 +1,69 @@ +expectException(SocketInvalidArgumentException::class); + /** @phpstan-ignore-next-line argument.type */ + new StreamChannel('not a resource'); + } + + public function testWriteAndReadOnAMemoryStream(): void + { + $stream = fopen('php://memory', 'r+'); + self::assertNotFalse($stream); + + $channel = new StreamChannel($stream); + self::assertSame(5, $channel->write('hello')); + + rewind($stream); + self::assertSame('hello', $channel->read(1024)); + // EOF after draining + self::assertNull($channel->read(1024)); + } + + public function testReadRejectsZeroLength(): void + { + $stream = fopen('php://memory', 'r+'); + self::assertNotFalse($stream); + + $channel = new StreamChannel($stream); + self::assertNull($channel->read(0)); + } + + public function testIsAliveTracksResourceState(): void + { + $stream = fopen('php://memory', 'r+'); + self::assertNotFalse($stream); + + $channel = new StreamChannel($stream); + self::assertTrue($channel->isAlive()); + fclose($stream); + self::assertFalse($channel->isAlive()); + } + + public function testCloseIsIdempotent(): void + { + $stream = fopen('php://memory', 'r+'); + self::assertNotFalse($stream); + + $channel = new StreamChannel($stream); + self::assertTrue($channel->close()); + self::assertNull($channel->getResource()); + self::assertFalse($channel->isAlive()); + self::assertNull($channel->read(1024)); + self::assertNull($channel->write('x')); + self::assertTrue($channel->close()); + } +} diff --git a/tests/Unit/Channel/TcpChannelTest.php b/tests/Unit/Channel/TcpChannelTest.php new file mode 100644 index 0000000..9bf54be --- /dev/null +++ b/tests/Unit/Channel/TcpChannelTest.php @@ -0,0 +1,93 @@ + */ + private array $pair = []; + + protected function setUp(): void + { + $pair = []; + self::assertTrue(socket_create_pair(\AF_UNIX, \SOCK_STREAM, 0, $pair)); + $this->pair = $pair; + // Make the peer non-blocking so isAlive's MSG_DONTWAIT behaves predictably. + socket_set_nonblock($this->pair[0]); + socket_set_nonblock($this->pair[1]); + } + + protected function tearDown(): void + { + foreach ($this->pair as $sock) { + if ($sock instanceof Socket) { + @socket_close($sock); + } + } + } + + public function testRoundTripWriteAndRead(): void + { + $channel = new TcpChannel($this->pair[0]); + $remote = $this->pair[1]; + + $bytes = $channel->write('payload'); + self::assertSame(7, $bytes); + + $received = ''; + $read = socket_recv($remote, $received, 1024, \MSG_DONTWAIT); + self::assertSame(7, $read); + self::assertSame('payload', $received); + } + + public function testReadFromPeerWrite(): void + { + $channel = new TcpChannel($this->pair[0]); + socket_send($this->pair[1], 'hi', 2, 0); + // Tiny wait to let the kernel deliver the bytes. + usleep(20_000); + self::assertSame('hi', $channel->read(1024)); + } + + public function testReadReturnsNullWhenNoDataAvailable(): void + { + $channel = new TcpChannel($this->pair[0]); + self::assertNull($channel->read(1024)); + } + + public function testIsAliveStaysTrueWithNoTrafficAndFlipsAfterPeerClose(): void + { + $channel = new TcpChannel($this->pair[0]); + self::assertTrue($channel->isAlive()); + + @socket_close($this->pair[1]); + // Remove from the cleanup set so tearDown doesn't double-close. + unset($this->pair[1]); + + self::assertFalse($channel->isAlive()); + } + + public function testCloseFreesResourceAndIsIdempotent(): void + { + $channel = new TcpChannel($this->pair[0]); + self::assertSame($this->pair[0], $channel->getResource()); + + self::assertTrue($channel->close()); + self::assertNull($channel->getResource()); + self::assertFalse($channel->isAlive()); + self::assertNull($channel->read(1024)); + self::assertNull($channel->write('x')); + // Second close is a no-op. + self::assertTrue($channel->close()); + // Avoid double-closing in tearDown. + unset($this->pair[0]); + } +} diff --git a/tests/Unit/Client/AbstractClientTest.php b/tests/Unit/Client/AbstractClientTest.php new file mode 100644 index 0000000..34987b4 --- /dev/null +++ b/tests/Unit/Client/AbstractClientTest.php @@ -0,0 +1,73 @@ +getHost()); + self::assertSame(4242, $client->getPort()); + } + + public function testEmptyHostIsRejected(): void + { + $this->expectException(SocketInvalidArgumentException::class); + new TcpClient('', 80); + } + + public function testZeroPortIsRejected(): void + { + $this->expectException(SocketInvalidArgumentException::class); + new UdpClient('127.0.0.1', 0); + } + + public function testOutOfRangePortIsRejected(): void + { + $this->expectException(SocketInvalidArgumentException::class); + new UdpClient('127.0.0.1', 100000); + } + + public function testGetSocketReturnsNullBeforeConnect(): void + { + $client = new TcpClient('127.0.0.1', 9999); + self::assertNull($client->getSocket()); + } + + public function testDisconnectIsIdempotentBeforeConnect(): void + { + $client = new TcpClient('127.0.0.1', 9999); + self::assertTrue($client->disconnect()); + self::assertTrue($client->disconnect()); + } + + public function testReadAndWriteReturnNullBeforeConnect(): void + { + $client = new TcpClient('127.0.0.1', 9999); + self::assertNull($client->read(64)); + self::assertNull($client->write('x')); + } + + public function testUdpReadAndWriteReturnNullBeforeConnect(): void + { + $client = new UdpClient('127.0.0.1', 9999); + self::assertNull($client->read(64)); + self::assertNull($client->write('x')); + self::assertNull($client->getSocket()); + self::assertTrue($client->disconnect()); + } + + public function testUdpReadRejectsZeroLength(): void + { + $client = new UdpClient('127.0.0.1', 9999); + self::assertNull($client->read(0)); + } +} diff --git a/tests/Unit/Client/AbstractStreamClientTest.php b/tests/Unit/Client/AbstractStreamClientTest.php new file mode 100644 index 0000000..889ba86 --- /dev/null +++ b/tests/Unit/Client/AbstractStreamClientTest.php @@ -0,0 +1,59 @@ +option('verify_peer', false) + ->option('verify_peer_name', false) + ->timeout(2.5) + ->blocking(false), + ); + } + + public function testCryptoBeforeConnectThrows(): void + { + $client = new TlsClient('127.0.0.1', 9443); + $this->expectException(SocketException::class); + $client->crypto(CryptoMethod::TLSv1_2); + } + + public function testReadAndWriteReturnNullBeforeConnect(): void + { + $client = new TlsClient('127.0.0.1', 9443); + self::assertNull($client->read(1024)); + self::assertNull($client->write('payload')); + self::assertNull($client->getSocket()); + self::assertTrue($client->disconnect()); + } + + public function testConnectFailsWithoutListener(): void + { + // Bind a port and immediately release it so we have a high-confidence + // "no listener here" target without racing other tests. + $sock = socket_create(\AF_INET, \SOCK_STREAM, \SOL_TCP); + self::assertNotFalse($sock); + socket_bind($sock, '127.0.0.1', 0); + $addr = ''; + $port = 0; + socket_getsockname($sock, $addr, $port); + socket_close($sock); + + $client = new TlsClient('127.0.0.1', $port, 0.3); + $this->expectException(SocketConnectionException::class); + $client->connect(); + } +} diff --git a/tests/Unit/Server/AbstractServerBroadcastTest.php b/tests/Unit/Server/AbstractServerBroadcastTest.php index b3386a1..e131b2b 100644 --- a/tests/Unit/Server/AbstractServerBroadcastTest.php +++ b/tests/Unit/Server/AbstractServerBroadcastTest.php @@ -75,6 +75,46 @@ public function testWaitRejectsNegative(): void $server->wait(-1.0); } + public function testWaitZeroIsANoop(): void + { + $server = new TestableServer('127.0.0.1', 9000); + $start = microtime(true); + $server->wait(0.0); + self::assertLessThan(0.01, microtime(true) - $start); + } + + public function testWaitPositiveSleeps(): void + { + $server = new TestableServer('127.0.0.1', 9000); + $start = microtime(true); + $server->wait(0.05); + self::assertGreaterThanOrEqual(0.04, microtime(true) - $start); + } + + public function testBroadcastByIdSkipsEvictedKey(): void + { + $server = new TestableServer('127.0.0.1', 9000); + $client = $this->makeConnection($channel); + $key = $server->attach($client); + $server->register('ghost', $client); + + // Force the underlying client out without going through the public + // close-then-eviction path, so 'ghost' still maps to an unknown key. + $server->forceEvict($key); + + // No write should reach anyone; the orphaned-id branch must short-circuit. + self::assertTrue($server->broadcast('hi', 'ghost')); + self::assertSame([], $this->writesOf($channel)); + } + + public function testIsRunningIsFalseByDefault(): void + { + $server = new TestableServer('127.0.0.1', 9000); + self::assertFalse($server->isRunning()); + $server->stop(); + self::assertFalse($server->isRunning()); + } + public function testGetClientsKeyedByIdWhenRegistered(): void { $server = new TestableServer('127.0.0.1', 9000); @@ -123,6 +163,11 @@ public function attach(SocketConnectionInterface $client): int return $this->addClient($client); } + public function forceEvict(int $key): void + { + $this->evict($key); + } + public function listen(): static { return $this; diff --git a/tests/Unit/Server/AbstractStreamServerTest.php b/tests/Unit/Server/AbstractStreamServerTest.php new file mode 100644 index 0000000..1992b46 --- /dev/null +++ b/tests/Unit/Server/AbstractStreamServerTest.php @@ -0,0 +1,51 @@ +option('local_cert', '/tmp/x.pem') + ->timeout(1.5) + ->blocking(false) + ->crypto(CryptoMethod::TLSv1_2), + ); + } + + public function testCryptoNullClearsContextOption(): void + { + $server = new TlsServer('127.0.0.1', 9443); + $server->crypto(CryptoMethod::TLSv1_2); + self::assertSame( + $server, + $server->crypto(null), + ); + } + + public function testCloseBeforeListenIsIdempotent(): void + { + $server = new TlsServer('127.0.0.1', 9443); + self::assertTrue($server->close()); + self::assertTrue($server->close()); + self::assertNull($server->getSocket()); + } + + public function testTickBeforeListenThrows(): void + { + $server = new TlsServer('127.0.0.1', 9443); + $this->expectException(SocketException::class); + $server->tick(static fn () => null, 0.0); + } + +} diff --git a/tests/Unit/Server/UdpServerOptionsTest.php b/tests/Unit/Server/UdpServerOptionsTest.php new file mode 100644 index 0000000..c3062c9 --- /dev/null +++ b/tests/Unit/Server/UdpServerOptionsTest.php @@ -0,0 +1,37 @@ +close()); + self::assertTrue($server->close()); + self::assertNull($server->getSocket()); + self::assertFalse($server->isRunning()); + } + + public function testTickBeforeListenThrows(): void + { + $server = new UdpServer('127.0.0.1', 9000); + $this->expectException(SocketException::class); + $server->tick(static fn () => null, 0.0); + } + + public function testGetHostAndPortReturnConfiguredValues(): void + { + $server = new UdpServer('192.168.0.5', 5300); + self::assertSame('192.168.0.5', $server->getHost()); + self::assertSame(5300, $server->getPort()); + } +}