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
12 changes: 11 additions & 1 deletion bin/test-servers.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
#
# Groups:
# engine — HTTP (19501), TCP (19502), WebSocket (19503), HTTP v2 (19505),
# Hypervel HTTP Server (19506)
# Hypervel HTTP Server (19506), mTLS (19507), SNI-only TLS (19508)
# grpc — Hypervel plaintext (19520), grpc-go plaintext (19521),
# Hypervel TLS (19522), grpc-go TLS (19523)
# reverb — Single-worker (19510), Redis scaling (19511), multi-worker (19512),
Expand Down Expand Up @@ -122,11 +122,21 @@ start_engine() {
PIDS+=($!)
echo " Hypervel HTTP Server starting on port 19506 (PID: $!)..."

setsid php "$PROJECT_DIR/tests/Integration/HttpServer/Fixtures/tls_server.php" mtls 19507 &
PIDS+=($!)
echo " mTLS test server starting on port 19507 (PID: $!)..."

setsid php "$PROJECT_DIR/tests/Integration/HttpServer/Fixtures/tls_server.php" sni 19508 &
PIDS+=($!)
echo " SNI-only TLS test server starting on port 19508 (PID: $!)..."

wait_for_tcp 19501 "HTTP"
wait_for_tcp 19502 "TCP packet"
wait_for_tcp 19503 "WebSocket"
wait_for_tcp 19505 "HTTP v2"
wait_for_server 19506 "Hypervel HTTP Server"
wait_for_tcp 19507 "mTLS"
wait_for_tcp 19508 "SNI-only TLS"
}

start_grpc() {
Expand Down
25 changes: 25 additions & 0 deletions docs/plans/2026-10-13-swoole-upstream-regression-coverage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Swoole Upstream Regression Coverage

## Goal

Keep deterministic Hypervel regressions for upstream Swoole defects that affect real framework and application paths. Tests remain skipped only while Hypervel's minimum Swoole release lacks the corresponding fix.

## Test Scope

- Cover hooked stream shutdown and immediate listener release (#6109 and #6274).
- Cover HTTP client reuse, rejected deferred settings, and closing an unconnected client (#6149, #6173/#6267, and #6186).
- Cover masked/compressed WebSocket frames, fresh masking keys, and invalid compressed control frames (#6158, #6165, and #6209).
- Cover coroutine socket timeout and linger option handling (#6166).
- Cover incremental and multipart request parsing, upload-marker safety, and QUERY requests (#6154, #6161, #6162, #6224, and #6256).
- Cover chunked request boundaries, `100 Continue`, and pipelined response ordering through the real Hypervel HTTP server (#6205 and #6220).
- Cover cancellation during directory iteration and `Coroutine::setTimeLimit()` timer ownership (#6185 and #6270).
- Cover client-certificate enforcement and SNI/default-certificate initialization through TLS integration tests (#6188 and #6214).

## Verification

Run each new regression without its version guard against the current Swoole extension and confirm it fails for the expected defect. Then add the narrow Swoole-version skip, including the upstream pull-request URL, and rerun the affected test class. Run the existing package-level suites for every changed area.

## Deferred Coverage

- #6126 (`Swoole\Table` atomic row operations) is not included for now because Hypervel does not currently use those APIs. Revisit it if the framework adopts them.
- #6174 (Unix datagram address ownership) is not included for now because Hypervel does not currently expose or use Unix datagram sockets. Revisit it if that changes.
40 changes: 40 additions & 0 deletions tests/Engine/CoroutineTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
use Hypervel\Engine\Exceptions\CoroutineDestroyedException;
use Hypervel\Engine\Exceptions\RuntimeException;
use Hypervel\Tests\TestCase;
use Swoole\Coroutine as SwooleCoroutine;
use Swoole\Coroutine\CanceledException;
use Swoole\Timer;
use Symfony\Component\Process\Process;
use Throwable;

class CoroutineTest extends TestCase
Expand Down Expand Up @@ -244,6 +247,43 @@ public function testCoroutineJoinReturnsFalseWhenItTimesOut(): void
$this->assertFalse(Coroutine::exists($coroutine->getId()));
}

public function testCancellationEscapesDirectoryIteration(): void
{
if (SWOOLE_VERSION_ID <= 60203) {
$this->markTestSkipped('Requires correct coroutine exception delivery from https://github.com/swoole/swoole-src/pull/6185.');
}

$process = new Process([
PHP_BINARY,
__DIR__ . '/Fixtures/cancel-directory-iterator.php',
dirname(__DIR__, 2) . '/vendor/autoload.php',
]);
$process->setTimeout(2);
$process->mustRun();

$this->assertSame("canceled\n", $process->getOutput());
}

public function testCoroutineTimeLimitOwnsOneFractionalTimer(): void
{
if (SWOOLE_VERSION_ID <= 60203) {
$this->markTestSkipped('Requires fractional and owned coroutine time-limit timers from https://github.com/swoole/swoole-src/pull/6270.');
}

$timerCount = Timer::stats()['num'];
$counts = [];

SwooleCoroutine::create(function () use (&$counts): void {
$this->assertTrue(SwooleCoroutine::setTimeLimit(0.05));
$counts[] = Timer::stats()['num'];
$this->assertTrue(SwooleCoroutine::setTimeLimit(0.1));
$counts[] = Timer::stats()['num'];
});

$this->assertSame([$timerCount + 1, $timerCount + 1], $counts);
$this->assertSame($timerCount, Timer::stats()['num']);
}

public function testCoroutineList(): void
{
$list = Coroutine::list();
Expand Down
28 changes: 28 additions & 0 deletions tests/Engine/Fixtures/cancel-directory-iterator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

use Swoole\Coroutine;
use Swoole\Coroutine\CanceledException;
use Swoole\Event;
use Swoole\Runtime;

require $argv[1];

Runtime::enableCoroutine(SWOOLE_HOOK_FILE);

Coroutine\run(static function (): void {
try {
$coroutine = Coroutine::getCid();

foreach (new DirectoryIterator(__DIR__) as $entry) {
Event::defer(static function () use ($coroutine): void {
Coroutine::cancel($coroutine, true);
});
}

exit(1);
} catch (CanceledException) {
echo "canceled\n";
}
});
67 changes: 67 additions & 0 deletions tests/Engine/HookedStreamTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

declare(strict_types=1);

namespace Hypervel\Tests\Engine;

use Hypervel\Tests\TestCase;

class HookedStreamTest extends TestCase
{
public function testSocketShutdownReportsTheNativeResult(): void
{
if (SWOOLE_VERSION_ID <= 60203) {
$this->markTestSkipped('Requires hooked stream shutdown result parity from https://github.com/swoole/swoole-src/pull/6109.');
}

$server = stream_socket_server('tcp://127.0.0.1:0');
$this->assertIsResource($server);
$address = stream_socket_get_name($server, false);
$this->assertIsString($address);
$sender = stream_socket_client('tcp://' . $address);
$receiver = stream_socket_accept($server, 1);

try {
$this->assertIsResource($sender);
$this->assertIsResource($receiver);
stream_set_timeout($receiver, 1);
$this->assertSame(7, fwrite($sender, 'payload'));
$this->assertTrue(stream_socket_shutdown($sender, STREAM_SHUT_WR));
$this->assertSame('payload', stream_get_contents($receiver));
Comment thread
greptile-apps[bot] marked this conversation as resolved.
$this->assertTrue(feof($receiver));
$this->assertFalse(@stream_socket_shutdown($sender, STREAM_SHUT_WR));
} finally {
foreach ([$sender, $receiver, $server] as $stream) {
if (is_resource($stream)) {
fclose($stream);
}
}
}
}

public function testClosingAListenerImmediatelyReleasesItsAddress(): void
{
if (SWOOLE_VERSION_ID <= 60203) {
$this->markTestSkipped('Requires immediate hooked listener release from https://github.com/swoole/swoole-src/pull/6274.');
}

$server = stream_socket_server('tcp://127.0.0.1:0');
$this->assertIsResource($server);
$address = stream_socket_get_name($server, false);
$this->assertIsString($address);

fclose($server);

$this->assertFalse(@stream_socket_client('tcp://' . $address));

$replacement = stream_socket_server('tcp://' . $address);

try {
$this->assertIsResource($replacement);
} finally {
if (is_resource($replacement)) {
fclose($replacement);
}
}
}
}
23 changes: 23 additions & 0 deletions tests/Engine/HttpClientTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace Hypervel\Tests\Engine;

use Hypervel\Engine\Http\Client;
use Hypervel\Tests\TestCase;

class HttpClientTest extends TestCase
{
public function testClosingAnUnconnectedClientReportsNoConnection(): void
{
if (SWOOLE_VERSION_ID <= 60203) {
$this->markTestSkipped('Requires initialized coroutine HTTP client socket state from https://github.com/swoole/swoole-src/pull/6186.');
}

$client = new Client('127.0.0.1', 80);

$this->assertFalse($client->close());
$this->assertSame(SWOOLE_ERROR_CLIENT_NO_CONNECTION, $client->errCode);
}
}
28 changes: 28 additions & 0 deletions tests/Engine/SocketTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@
use Hypervel\Engine\SafeSocket;
use Hypervel\Engine\Socket;
use Hypervel\Tests\TestCase;
use PHPUnit\Framework\Attributes\RunInSeparateProcess;
use ReflectionProperty;
use RuntimeException;
use Swoole\Coroutine as SwooleCoroutine;
use Swoole\Coroutine\Server;
use Swoole\Coroutine\Socket as SwooleSocket;
use Swoole\Coroutine\WaitGroup;
use Throwable;
use ValueError;

use function Hypervel\Coroutine\go;

Expand Down Expand Up @@ -236,6 +238,32 @@ function (int $port): void {
);
}

#[RunInSeparateProcess]
public function testSocketOptionsAcceptObjectsAndReportTheInvalidLingerField(): void
{
if (SWOOLE_VERSION_ID <= 60203) {
$this->markTestSkipped('Requires safe coroutine socket option validation from https://github.com/swoole/swoole-src/pull/6166.');
}

$socket = new Socket(AF_INET, SOCK_STREAM, SOL_TCP);

try {
$timeout = (object) ['sec' => 1, 'usec' => 0];

$this->assertTrue($socket->setOption(SOL_SOCKET, SO_RCVTIMEO, $timeout));
$this->assertTrue($socket->setOption(SOL_SOCKET, SO_SNDTIMEO, $timeout));

try {
$socket->setOption(SOL_SOCKET, SO_LINGER, ['l_onoff' => 1, 'l_linger' => 65536]);
$this->fail('Expected an invalid linger value to be rejected.');
} catch (ValueError $exception) {
$this->assertStringContainsString('"l_linger"', $exception->getMessage());
}
} finally {
$socket->close();
}
}

public function testServerHandlerExceptionSurfacesInTheParent(): void
{
$server = new Server('127.0.0.1', 0);
Expand Down
79 changes: 79 additions & 0 deletions tests/Engine/WebSocketTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,85 @@ public function testFrameModelsTheNativeMaskFlag(): void
$this->assertTrue(Frame::from($swooleFrame)->getMask());
}

public function testNativeFramesDecodeMaskedCompressedPayloadsWithoutMutatingTheInput(): void
{
if (! defined('SWOOLE_HAVE_ZLIB')) {
$this->markTestSkipped('Swoole was not compiled with zlib support.');
}

if (SWOOLE_VERSION_ID <= 60203) {
$this->markTestSkipped('Requires input-preserving WebSocket decoding from https://github.com/swoole/swoole-src/pull/6158.');
}

$payload = str_repeat('hypervel-websocket-frame:', 20);
$packed = SwooleFrame::pack(
$payload,
Opcode::TEXT,
SWOOLE_WEBSOCKET_FLAG_FIN | SWOOLE_WEBSOCKET_FLAG_MASK | SWOOLE_WEBSOCKET_FLAG_COMPRESS,
);
$original = bin2hex($packed);

$this->assertNotSame(0, ord($packed[0]) & 0x40);
$this->assertNotSame(0, ord($packed[1]) & 0x80);

$frame = SwooleFrame::unpack($packed);

$this->assertInstanceOf(SwooleFrame::class, $frame);
$this->assertSame($payload, $frame->data);
$this->assertSame($original, bin2hex($packed));
}

public function testMaskedFramesUseFreshKeys(): void
{
if (SWOOLE_VERSION_ID <= 60203) {
$this->markTestSkipped('Requires fresh WebSocket masking keys from https://github.com/swoole/swoole-src/pull/6165.');
}

$keys = [];

for ($attempt = 0; $attempt < 8; ++$attempt) {
$packed = (string) new Frame(mask: true, payloadData: 'hello');
$keys[] = substr($packed, 2, 4);

$this->assertSame('hello', SwooleFrame::unpack($packed)->data);
}

$this->assertGreaterThan(1, count(array_unique($keys)));
}

public function testCompressedControlFramesAreRejected(): void
{
if (! defined('SWOOLE_HAVE_ZLIB')) {
$this->markTestSkipped('Swoole was not compiled with zlib support.');
}

if (SWOOLE_VERSION_ID <= 60203) {
$this->markTestSkipped('Requires compressed control-frame rejection from https://github.com/swoole/swoole-src/pull/6209.');
}

$payload = "\x03\xe8bye";
$packed = SwooleFrame::pack(
$payload,
Opcode::TEXT,
SWOOLE_WEBSOCKET_FLAG_FIN | SWOOLE_WEBSOCKET_FLAG_COMPRESS,
);
$textFrame = SwooleFrame::unpack($packed);

$this->assertNotSame(0, ord($packed[0]) & 0x40);
$this->assertSame(Opcode::TEXT, ord($packed[0]) & 0x0F);
$this->assertInstanceOf(SwooleFrame::class, $textFrame);
$this->assertSame($payload, $textFrame->data);

foreach ([Opcode::CLOSE, Opcode::PING, Opcode::PONG] as $opcode) {
$invalid = $packed;
$invalid[0] = chr((ord($invalid[0]) & 0xF0) | $opcode);

$this->assertNotSame(0, ord($invalid[0]) & 0x40);
$this->assertSame($opcode, ord($invalid[0]) & 0x0F);
$this->assertFalse(@SwooleFrame::unpack($invalid));
}
}

public function testResponseReturnsTheNativeResponsePushResult(): void
{
$connection = new EngineTestSwooleResponse;
Expand Down
7 changes: 5 additions & 2 deletions tests/Filesystem/FilesystemManagerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -829,7 +829,7 @@ public function testReadThroughDiskPromotionFailuresAreBestEffortByDefault(): vo

public function testReadThroughPromotionCancellationClosesOwnedStreams(): void
{
$streamCount = count(get_resources('stream'));
$initialStreamIds = array_keys(get_resources('stream'));
$source = fopen('php://temp', 'w+b');
fwrite($source, 'contents');
rewind($source);
Expand All @@ -856,7 +856,10 @@ static function (string $path, mixed $stream) use (&$temporary, $cancellation):
$this->assertFalse(is_resource($source));
$this->assertNotNull($temporary);
$this->assertFalse(is_resource($temporary));
$this->assertCount($streamCount, get_resources('stream'));
$this->assertCount(
0,
array_diff_key(get_resources('stream'), array_fill_keys($initialStreamIds, true)),
);
} finally {
if (is_resource($source)) {
fclose($source);
Expand Down
Loading
Loading