Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/FreeDSx/Socket/SocketServer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
74 changes: 74 additions & 0 deletions src/FreeDSx/Socket/SocketServerOptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<string, bool|int>
*/
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;
}
}
35 changes: 35 additions & 0 deletions tests/unit/RequiresNonWindows.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

/**
* This file is part of the FreeDSx Socket package.
*
* (c) Chad Sikorra <Chad.Sikorra@gmail.com>
*
* 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.');
}
}
103 changes: 103 additions & 0 deletions tests/unit/SocketServerOptionsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php

declare(strict_types=1);

/**
* This file is part of the FreeDSx Socket package.
*
* (c) Chad Sikorra <Chad.Sikorra@gmail.com>
*
* 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(),
);
}
}
21 changes: 21 additions & 0 deletions tests/unit/SocketServerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

final class SocketServerTest extends TestCase
{
use RequiresNonWindows;
use RequiresUnixTransport;

private string $testSocket = '';
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 2 additions & 3 deletions tests/unit/SocketTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@

final class SocketTest extends TestCase
{
use RequiresNonWindows;
use RequiresUnixTransport;

/**
Expand Down Expand Up @@ -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(
Expand Down
7 changes: 4 additions & 3 deletions tests/unit/Timeout/BlockingSelectEnforcerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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();

Expand Down
7 changes: 4 additions & 3 deletions tests/unit/Timeout/SwooleTimerEnforcerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,21 @@
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
{
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();
}
Expand Down
Loading