From e0ca85a5a00a790cb6b6cce09fa52b1e7dc77ae6 Mon Sep 17 00:00:00 2001 From: Chad Sikorra Date: Sun, 26 Jul 2026 10:17:27 -0400 Subject: [PATCH] Add options for socket reuse. This is needed as I refactor the swoole runner on the LDAP library side. --- src/FreeDSx/Socket/SocketServer.php | 15 +++ src/FreeDSx/Socket/SocketServerOptions.php | 74 +++++++++++++ tests/unit/RequiresNonWindows.php | 35 ++++++ tests/unit/SocketServerOptionsTest.php | 103 ++++++++++++++++++ tests/unit/SocketServerTest.php | 21 ++++ tests/unit/SocketTest.php | 5 +- .../Timeout/BlockingSelectEnforcerTest.php | 7 +- .../unit/Timeout/SwooleTimerEnforcerTest.php | 7 +- 8 files changed, 258 insertions(+), 9 deletions(-) create mode 100644 tests/unit/RequiresNonWindows.php create mode 100644 tests/unit/SocketServerOptionsTest.php diff --git a/src/FreeDSx/Socket/SocketServer.php b/src/FreeDSx/Socket/SocketServer.php index 9272e2e..39a07a8 100644 --- a/src/FreeDSx/Socket/SocketServer.php +++ b/src/FreeDSx/Socket/SocketServer.php @@ -44,6 +44,21 @@ public function getOptions(): SocketServerOptions return $this->options; } + /** + * Adds the socket-level options, which only a listening socket has any use for. + * + * @return resource + */ + protected function createSocketContext() + { + $this->context = stream_context_create([ + 'ssl' => $this->options->toStreamContextSslOptions(), + 'socket' => $this->getOptions()->toStreamContextSocketOptions(), + ]); + + return $this->context; + } + /** * Create the socket server and bind to a specific port to listen for clients. * diff --git a/src/FreeDSx/Socket/SocketServerOptions.php b/src/FreeDSx/Socket/SocketServerOptions.php index 96b4e5e..1079e4c 100644 --- a/src/FreeDSx/Socket/SocketServerOptions.php +++ b/src/FreeDSx/Socket/SocketServerOptions.php @@ -24,6 +24,12 @@ final class SocketServerOptions implements SocketOptionsInterface private int $writeTimeout = 0; + private bool $reusePort = false; + + private bool $reuseAddress = false; + + private ?int $backlog = null; + public function __construct() { $this->setSslValidateCert(false); @@ -59,4 +65,72 @@ public function getWriteTimeout(): int { return $this->writeTimeout; } + + /** + * Allow several processes to bind their own socket to the same address, letting the kernel distribute incoming + * connections between them (Linux 3.9+; semantics differ on other platforms). + */ + public function setReusePort(bool $reusePort): self + { + $this->reusePort = $reusePort; + + return $this; + } + + public function isReusePort(): bool + { + return $this->reusePort; + } + + /** + * Bind even while the address is in the kernel's TIME_WAIT state, so a restart does not have to wait it out. + */ + public function setReuseAddress(bool $reuseAddress): self + { + $this->reuseAddress = $reuseAddress; + + return $this; + } + + public function isReuseAddress(): bool + { + return $this->reuseAddress; + } + + /** + * Pending connections the kernel queues before refusing them, or null for the system default. + */ + public function setBacklog(?int $backlog): self + { + $this->backlog = $backlog; + + return $this; + } + + public function getBacklog(): ?int + { + return $this->backlog; + } + + /** + * The socket-level stream context options, omitting anything left at its default so the kernel decides. + * + * @return array + */ + public function toStreamContextSocketOptions(): array + { + $opts = []; + + if ($this->reusePort) { + $opts['so_reuseport'] = true; + } + if ($this->reuseAddress) { + $opts['so_reuseaddr'] = true; + } + if ($this->backlog !== null) { + $opts['backlog'] = $this->backlog; + } + + return $opts; + } } diff --git a/tests/unit/RequiresNonWindows.php b/tests/unit/RequiresNonWindows.php new file mode 100644 index 0000000..c620ba7 --- /dev/null +++ b/tests/unit/RequiresNonWindows.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Socket; + +trait RequiresNonWindows +{ + /** + * Skips a test whose behaviour depends on socket semantics Windows does not share. + */ + private function requireNonWindows(string $reason): void + { + if (DIRECTORY_SEPARATOR === '\\') { + self::markTestSkipped($reason); + } + } + + /** + * Windows cannot be made to stall a send by filling the socket buffer. + */ + private function requireFillableSocket(): void + { + $this->requireNonWindows('Cannot fill the socket to force a send stall on Windows.'); + } +} diff --git a/tests/unit/SocketServerOptionsTest.php b/tests/unit/SocketServerOptionsTest.php new file mode 100644 index 0000000..8c093ac --- /dev/null +++ b/tests/unit/SocketServerOptionsTest.php @@ -0,0 +1,103 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Tests\Unit\FreeDSx\Socket; + +use FreeDSx\Socket\SocketServerOptions; +use PHPUnit\Framework\TestCase; + +final class SocketServerOptionsTest extends TestCase +{ + private SocketServerOptions $subject; + + protected function setUp(): void + { + $this->subject = new SocketServerOptions(); + } + + public function test_it_sets_no_socket_options_by_default(): void + { + self::assertSame( + [], + $this->subject->toStreamContextSocketOptions(), + ); + } + + public function test_it_defaults_the_socket_options_to_off(): void + { + self::assertFalse($this->subject->isReusePort()); + self::assertFalse($this->subject->isReuseAddress()); + self::assertNull($this->subject->getBacklog()); + } + + public function test_it_emits_the_port_reuse_option(): void + { + $this->subject->setReusePort(true); + + self::assertSame( + ['so_reuseport' => true], + $this->subject->toStreamContextSocketOptions(), + ); + } + + public function test_it_emits_the_address_reuse_option(): void + { + $this->subject->setReuseAddress(true); + + self::assertSame( + ['so_reuseaddr' => true], + $this->subject->toStreamContextSocketOptions(), + ); + } + + public function test_it_emits_the_backlog_option(): void + { + $this->subject->setBacklog(256); + + self::assertSame( + ['backlog' => 256], + $this->subject->toStreamContextSocketOptions(), + ); + } + + public function test_it_emits_every_option_that_was_set(): void + { + $this->subject + ->setReusePort(true) + ->setReuseAddress(true) + ->setBacklog(128); + + self::assertSame( + [ + 'so_reuseport' => true, + 'so_reuseaddr' => true, + 'backlog' => 128, + ], + $this->subject->toStreamContextSocketOptions(), + ); + } + + public function test_it_omits_an_option_that_was_switched_back_off(): void + { + $this->subject + ->setReusePort(true) + ->setReusePort(false) + ->setBacklog(64) + ->setBacklog(null); + + self::assertSame( + [], + $this->subject->toStreamContextSocketOptions(), + ); + } +} diff --git a/tests/unit/SocketServerTest.php b/tests/unit/SocketServerTest.php index 5ddbb59..e2eb744 100644 --- a/tests/unit/SocketServerTest.php +++ b/tests/unit/SocketServerTest.php @@ -23,6 +23,7 @@ final class SocketServerTest extends TestCase { + use RequiresNonWindows; use RequiresUnixTransport; private string $testSocket = ''; @@ -58,6 +59,26 @@ public function test_it_should_return_null_if_there_is_no_client_on_accept(): vo self::assertNull($this->subject->accept(0)); } + public function test_it_should_only_allow_a_second_server_on_the_same_port_when_reusing_it(): void + { + $this->requireNonWindows('Windows has no SO_REUSEPORT and already permits rebinding a port without it.'); + + $this->subject = (new SocketServer( + (new SocketServerOptions())->setReusePort(true), + ))->listen('127.0.0.1', 33390); + + $second = (new SocketServer( + (new SocketServerOptions())->setReusePort(true), + ))->listen('127.0.0.1', 33390); + $second->close(); + + $withoutReuse = new SocketServer(new SocketServerOptions()); + + $this->expectException(ConnectionException::class); + + $withoutReuse->listen('127.0.0.1', 33390); + } + public function test_it_should_construct_a_tcp_based_socket_server(): void { $this->subject = SocketServer::bindTcp('0.0.0.0', 33389); diff --git a/tests/unit/SocketTest.php b/tests/unit/SocketTest.php index 4c30d46..69bc698 100644 --- a/tests/unit/SocketTest.php +++ b/tests/unit/SocketTest.php @@ -26,6 +26,7 @@ final class SocketTest extends TestCase { + use RequiresNonWindows; use RequiresUnixTransport; /** @@ -121,9 +122,7 @@ public function test_a_bounded_write_sends_all_data_when_the_peer_reads(): void public function test_a_bounded_write_throws_when_the_peer_stops_reading(): void { - if (DIRECTORY_SEPARATOR === '\\') { - self::markTestSkipped('Cannot fill the socket to force a send stall on Windows.'); - } + $this->requireFillableSocket(); [$local] = $this->createSocketPair(); $subject = new Socket( diff --git a/tests/unit/Timeout/BlockingSelectEnforcerTest.php b/tests/unit/Timeout/BlockingSelectEnforcerTest.php index 454ab3d..1af698b 100644 --- a/tests/unit/Timeout/BlockingSelectEnforcerTest.php +++ b/tests/unit/Timeout/BlockingSelectEnforcerTest.php @@ -16,9 +16,12 @@ use FreeDSx\Socket\Exception\WriteTimeoutException; use FreeDSx\Socket\Timeout\BlockingSelectEnforcer; use PHPUnit\Framework\TestCase; +use Tests\Unit\FreeDSx\Socket\RequiresNonWindows; final class BlockingSelectEnforcerTest extends TestCase { + use RequiresNonWindows; + private BlockingSelectEnforcer $subject; /** @@ -77,9 +80,7 @@ public function test_it_restores_blocking_mode_after_a_successful_write(): void public function test_it_throws_when_the_peer_stops_reading(): void { - if (DIRECTORY_SEPARATOR === '\\') { - self::markTestSkipped('Cannot fill the socket to force a send stall on Windows.'); - } + $this->requireFillableSocket(); [$local] = $this->createSocketPair(); diff --git a/tests/unit/Timeout/SwooleTimerEnforcerTest.php b/tests/unit/Timeout/SwooleTimerEnforcerTest.php index b8540b0..1df76f5 100644 --- a/tests/unit/Timeout/SwooleTimerEnforcerTest.php +++ b/tests/unit/Timeout/SwooleTimerEnforcerTest.php @@ -18,10 +18,13 @@ use PHPUnit\Framework\TestCase; use Swoole\Coroutine; use Swoole\Runtime; +use Tests\Unit\FreeDSx\Socket\RequiresNonWindows; use Throwable; final class SwooleTimerEnforcerTest extends TestCase { + use RequiresNonWindows; + private SwooleTimerEnforcer $subject; protected function setUp(): void @@ -29,9 +32,7 @@ protected function setUp(): void if (!extension_loaded('swoole')) { self::markTestSkipped('The swoole extension is required.'); } - if (DIRECTORY_SEPARATOR === '\\') { - self::markTestSkipped('Cannot fill the socket to force a send stall on Windows.'); - } + $this->requireFillableSocket(); $this->subject = new SwooleTimerEnforcer(); }