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/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/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..24628f8 --- /dev/null +++ b/src/Channel/StreamChannel.php @@ -0,0 +1,79 @@ +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,88 @@ 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 connection(): SocketServerInterface; + public function listen(): static; /** - * @return bool + * Stop listening and close every active client connection. */ - public function disconnect(): bool; + public function close(): bool; /** - * @param string $message - * @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 broadcast(string $message): bool; + public function broadcast(string $message, int|string|array|null $clients = null): bool; /** - * @param callable $callback - * @param int $usleep - * @return void + * Associate an identifier with a connection so it can be addressed by + * {@see self::broadcast()}. */ - public function live(callable $callback, int $usleep = 100000): void; + public function register(int|string $id, SocketConnectionInterface $client): bool; /** - * @param int|float $second - * @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 wait($second): void; + public function live(callable $callback, float $idleSeconds = 0.05): 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 tick(callable $callback, float $waitSeconds = 0.0): int; + + /** + * Cooperatively stop the loop started by {@see self::live()}. + */ + 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). + */ + 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..8bfad3c 100644 --- a/src/Server/TCP.php +++ b/src/Server/TCP.php @@ -1,77 +1,199 @@ - * @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\SocketInvalidArgumentException; +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 SocketInvalidArgumentException('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/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/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/ServerLifecycleTest.php b/tests/Integration/ServerLifecycleTest.php new file mode 100644 index 0000000..7feac41 --- /dev/null +++ b/tests/Integration/ServerLifecycleTest.php @@ -0,0 +1,91 @@ +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 testStopFromInsideCallbackExitsLiveLoop(): 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(...)); + + // 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()); + } +} 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/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/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 new file mode 100644 index 0000000..ff10b7d --- /dev/null +++ b/tests/Integration/TlsEchoTest.php @@ -0,0 +1,101 @@ +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->runClientChild($port); + // Hard-exit so PHPUnit shutdown handlers don't run in the child. + exit($exitCode); + } + + 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(); + + $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), 'client child did not exit cleanly'); + self::assertSame(0, pcntl_wexitstatus($status), 'client child reported failure'); + + self::assertSame('hello-tls', $received); + } finally { + if (posix_kill($pid, 0)) { + posix_kill($pid, \SIGTERM); + pcntl_waitpid($pid, $_status); + } + } + } + + private function runClientChild(int $port): int + { + try { + // Give the parent a beat 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(); + $client->write('hello-tls'); + + $reply = null; + for ($i = 0; $i < 200 && $reply === null; ++$i) { + $reply = $client->read(1024); + if ($reply === null) { + usleep(20_000); + } + } + $client->disconnect(); + + return $reply === 'echo:hello-tls' ? 0 : 11; + } catch (\Throwable $e) { + fwrite(\STDERR, 'tls client child error: ' . $e->getMessage() . "\n"); + + return 1; + } + } +} 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/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/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/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/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/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 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); + $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 forceEvict(int $key): void + { + $this->evict($key); + } + + 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/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/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/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()); + } +} 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()); + } +} 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); + } +}