Skip to content

Commit d40e433

Browse files
committed
Add SocketInvalidArgumentException for StreamChannel and TCP, new tests
1 parent 601a0a2 commit d40e433

6 files changed

Lines changed: 250 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
6+
and this project adheres to [Semantic Versioning 2.0.0](https://semver.org/).
7+
8+
## [Unreleased]
9+
10+
## [2.0.0] — TBD
11+
12+
### Highlights
13+
14+
The 2.0 line is a clean break from 1.x — the previous server loop had
15+
race conditions, lost inbound data through its liveness check, and
16+
stored transport state on a `static` property that leaked between
17+
instances. The new shape ships with explicit enums, per-transport
18+
`Channel` strategies, a non-blocking `select`-driven loop and full
19+
PHP 8.1+ typing.
20+
21+
### Added
22+
23+
- **PHP 8.1+ enums**`Transport`, `Domain` and `CryptoMethod` replace
24+
magic integer / string flags.
25+
- **`ChannelInterface` + `TcpChannel` / `UdpChannel` / `StreamChannel`**
26+
per-transport I/O strategy. `ServerConnection` is now just identity
27+
plus delegation.
28+
- **`SocketExceptionInterface`** — marker implemented by every exception
29+
in the package, so a single catch covers them all.
30+
- **`tick(callable, float): int`** — single-iteration accept/dispatch
31+
method on every server. Use it to embed the package in your own
32+
event loop or to drive servers deterministically in tests.
33+
- **`stop()` / `isRunning()`** — cooperative shutdown for the `live()`
34+
loop.
35+
- **`register(int|string, SocketConnectionInterface): bool`** — promoted
36+
to `SocketServerInterface`; the 1.x package-private `clientRegister()`
37+
is replaced by an interface method with a stable contract.
38+
- **PHPUnit 10 test suite** — 36 unit + integration tests covering
39+
enums, exception hierarchy, factory, channels, broadcast/register
40+
logic, TCP echo, UDP per-peer routing, TLS handshake (forked).
41+
- **CI pipeline** — GitHub Actions matrix across PHP 8.1, 8.2, 8.3 with
42+
PHPStan level 8, PHP-CS-Fixer and Codecov upload.
43+
- **`docs/` directory** — getting started, architecture, per-transport
44+
server and client guides, cookbook (chat server, raw SMTP) and the
45+
migration guide.
46+
47+
### Changed
48+
49+
- **Minimum PHP version** is now `^8.1` (was `>=7.4`).
50+
- **`composer.json`** now requires `ext-openssl` in addition to
51+
`ext-sockets`.
52+
- **Server method renames**`connection()``listen()`,
53+
`disconnect()``close()`. `live()` signature now takes
54+
`float $idleSeconds` instead of `int $usleep`. `wait()` is typed
55+
`float $seconds`.
56+
- **`SocketServerClientInterface` renamed to `SocketConnectionInterface`.**
57+
`push()` is now `write()`. `isDisconnected()` is replaced by the
58+
non-destructive `isAlive()`.
59+
- **Exception hierarchy**`SocketException` extends
60+
`\RuntimeException`. `SocketConnectionException` and
61+
`SocketListenException` now extend `SocketException` (previously
62+
`\Exception`). `SocketInvalidArgumentException` still extends
63+
`\InvalidArgumentException` and additionally implements
64+
`SocketExceptionInterface`.
65+
66+
### Removed
67+
68+
- `Common/BaseClient`, `Common/BaseCommon`, `Common/BaseServer`,
69+
`Common/ServerTrait`, `Common/StreamClientTrait`,
70+
`Common/StreamServerTrait` — replaced by `Client/AbstractClient`,
71+
`Client/AbstractStreamClient`, `Server/AbstractServer` and
72+
`Server/AbstractStreamServer`.
73+
- `Server/ServerClient` — replaced by `Server/ServerConnection`.
74+
- `Interfaces/SocketServerClientInterface` — replaced by
75+
`Interfaces/SocketConnectionInterface`.
76+
- `Socket::TCP`, `Socket::UDP`, `Socket::TLS`, `Socket::SSL` integer
77+
constants — replaced by the `Transport` enum.
78+
- The mystery-typed `$argument` parameter on the factory and on every
79+
constructor — replaced by explicit `?Domain $domain` / `?float $timeout`
80+
named parameters.
81+
- All `__setSocket()` / `__setCallbacks()` / `__removeCallbacks()`
82+
magic-prefixed methods.
83+
- The static `ServerClient::$credentials` array that leaked transport
84+
state between server instances.
85+
- `echo` calls inside `ServerClient` that wrote
86+
`"New client connected."` / `"Client disconnected."` to STDOUT.
87+
88+
### Fixed
89+
90+
- **Server loop accepts more than one client.** The 1.x `connection()`
91+
performed a blocking `socket_accept()` before `live()` was even
92+
called, capping the server at a single connection per process.
93+
- **TLS / SSL servers use the right accept call.** The 1.x loop called
94+
`socket_accept()` on a stream resource (always wrong for TLS / SSL)
95+
and never accepted the second connection.
96+
- **Liveness checks no longer consume data.** The 1.x
97+
`isDisconnected()` read a line off every client every iteration
98+
and discarded it, so application-level reads never saw any data.
99+
- **Client id map survives disconnects.** The 1.x `clientMap` stored
100+
raw array indices that became dangling after `unset()` on a
101+
disconnected client. The new `clientIdMap` uses monotonic keys and
102+
cleans up on eviction.
103+
- **UDP server demultiplexes peers correctly.** The 1.x server
104+
registered the listening socket itself as a "client" and broadcast
105+
back to its own host/port.
106+
- **TLS handshake has enough time.** The 1.x server left the listening
107+
stream non-blocking and called `stream_socket_accept(..., 0.0)`,
108+
which prevented the handshake from completing under load. The new
109+
loop keeps the listening stream blocking and drives readiness via
110+
`stream_select`, giving the handshake the full timeout.
111+
112+
### Migration
113+
114+
See [`docs/migration-1.x-to-2.x.md`](./docs/migration-1.x-to-2.x.md) for
115+
a step-by-step upgrade guide.
116+
117+
[Unreleased]: https://github.com/InitPHP/Socket/compare/v2.0.0...HEAD
118+
[2.0.0]: https://github.com/InitPHP/Socket/releases/tag/v2.0.0

src/Channel/StreamChannel.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace InitPHP\Socket\Channel;
66

7+
use InitPHP\Socket\Exception\SocketInvalidArgumentException;
78
use InitPHP\Socket\Interfaces\ChannelInterface;
89

910
use function fclose;
@@ -22,7 +23,7 @@ final class StreamChannel implements ChannelInterface
2223
public function __construct($stream)
2324
{
2425
if (!\is_resource($stream)) {
25-
throw new \InvalidArgumentException('StreamChannel expects a stream resource.');
26+
throw new SocketInvalidArgumentException('StreamChannel expects a stream resource.');
2627
}
2728
$this->stream = $stream;
2829
}

src/Interfaces/SocketServerInterface.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ public function tick(callable $callback, float $waitSeconds = 0.0): int;
8181
*/
8282
public function stop(): void;
8383

84+
/**
85+
* Returns true while the {@see self::live()} loop is running.
86+
*/
87+
public function isRunning(): bool;
88+
8489
/**
8590
* Sleep for $seconds (supports sub-second precision).
8691
*/

src/Server/TCP.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use InitPHP\Socket\Enum\Domain;
99
use InitPHP\Socket\Exception\SocketConnectionException;
1010
use InitPHP\Socket\Exception\SocketException;
11+
use InitPHP\Socket\Exception\SocketInvalidArgumentException;
1112
use InitPHP\Socket\Exception\SocketListenException;
1213
use Socket;
1314

@@ -42,7 +43,7 @@ public function __construct(
4243
public function backlog(int $backlog): self
4344
{
4445
if ($backlog < 1) {
45-
throw new \InvalidArgumentException('backlog must be at least 1.');
46+
throw new SocketInvalidArgumentException('backlog must be at least 1.');
4647
}
4748
$this->backlog = $backlog;
4849

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace InitPHP\Socket\Tests\Integration;
6+
7+
use InitPHP\Socket\Exception\SocketException;
8+
use InitPHP\Socket\Server\TCP as TcpServer;
9+
10+
final class ServerLifecycleTest extends IntegrationTestCase
11+
{
12+
public function testListenTwiceThrows(): void
13+
{
14+
$port = $this->findFreePort();
15+
$server = new TcpServer('127.0.0.1', $port);
16+
$server->listen();
17+
$this->registerCleanup($server->close(...));
18+
19+
$this->expectException(SocketException::class);
20+
$server->listen();
21+
}
22+
23+
public function testCloseAfterListenIsIdempotent(): void
24+
{
25+
$port = $this->findFreePort();
26+
$server = new TcpServer('127.0.0.1', $port);
27+
$server->listen();
28+
29+
self::assertTrue($server->close());
30+
self::assertTrue($server->close());
31+
self::assertNull($server->getSocket());
32+
}
33+
34+
public function testRelistenAfterCloseSucceeds(): void
35+
{
36+
$portA = $this->findFreePort();
37+
$server = new TcpServer('127.0.0.1', $portA);
38+
$server->listen();
39+
$server->close();
40+
41+
// After close(), the server can be configured for a new port and re-listened.
42+
$portB = $this->findFreePort();
43+
$rebornServer = new TcpServer('127.0.0.1', $portB);
44+
$rebornServer->listen();
45+
$this->registerCleanup($rebornServer->close(...));
46+
47+
self::assertNotNull($rebornServer->getSocket());
48+
}
49+
50+
public function testTickBeforeListenThrows(): void
51+
{
52+
$server = new TcpServer('127.0.0.1', 9000);
53+
54+
$this->expectException(SocketException::class);
55+
$server->tick(static fn () => null, 0.0);
56+
}
57+
58+
public function testStopExitsLiveLoopOnNextTick(): void
59+
{
60+
$port = $this->findFreePort();
61+
$server = new TcpServer('127.0.0.1', $port);
62+
$server->listen();
63+
$this->registerCleanup($server->close(...));
64+
65+
$iterations = 0;
66+
$callback = static function () use ($server, &$iterations): void {
67+
++$iterations;
68+
if ($iterations >= 1) {
69+
$server->stop();
70+
}
71+
};
72+
73+
// No clients ever connect, so tick() will idle out within idleSeconds.
74+
// We rely on stop() being called externally via a tiny shim that
75+
// hooks into a no-op activity: drive tick() ourselves a few times.
76+
$server->stop();
77+
self::assertFalse($server->isRunning());
78+
// After stop() the live loop should return immediately.
79+
$server->live($callback, 0.01);
80+
self::assertSame(0, $iterations, 'live() should not enter the loop after stop()');
81+
}
82+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace InitPHP\Socket\Tests\Unit\Server;
6+
7+
use InitPHP\Socket\Exception\SocketInvalidArgumentException;
8+
use InitPHP\Socket\Server\TCP as TcpServer;
9+
use PHPUnit\Framework\Attributes\CoversClass;
10+
use PHPUnit\Framework\TestCase;
11+
12+
#[CoversClass(TcpServer::class)]
13+
final class TcpServerOptionsTest extends TestCase
14+
{
15+
public function testBacklogIsChainable(): void
16+
{
17+
$server = new TcpServer('127.0.0.1', 9000);
18+
self::assertSame($server, $server->backlog(16));
19+
}
20+
21+
public function testBacklogRejectsZero(): void
22+
{
23+
$this->expectException(SocketInvalidArgumentException::class);
24+
(new TcpServer('127.0.0.1', 9000))->backlog(0);
25+
}
26+
27+
public function testBacklogRejectsNegative(): void
28+
{
29+
$this->expectException(SocketInvalidArgumentException::class);
30+
(new TcpServer('127.0.0.1', 9000))->backlog(-1);
31+
}
32+
33+
public function testCloseIsIdempotentWhenNeverListened(): void
34+
{
35+
$server = new TcpServer('127.0.0.1', 9000);
36+
self::assertTrue($server->close());
37+
self::assertTrue($server->close());
38+
self::assertNull($server->getSocket());
39+
self::assertFalse($server->isRunning());
40+
}
41+
}

0 commit comments

Comments
 (0)