diff --git a/.gitignore b/.gitignore
index 379ab4b..c9f54f1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,9 @@
.DS_Store
.phpunit.result.cache
+.ac-php-conf.json
build/
composer.lock
composer.phar
-vendor/
\ No newline at end of file
+vendor/
+node_modules/
+package-lock.json
diff --git a/README.md b/README.md
index c787131..efe9d05 100644
--- a/README.md
+++ b/README.md
@@ -3,12 +3,9 @@
[](https://github.com/Textalk/websocket-php/actions)
[](https://coveralls.io/github/Textalk/websocket-php)
-## Archived project
+## Description
-This project has been archived and is no longer maintained. No bug fix and no additional features will be added.
-You won't be able to submit new issues or pull requests, and no additional features will be added
-
-This library has been replaced by [sirn-se/websocket-php](https://github.com/sirn-se/websocket-php)
+This project was forked from archived project [Textalk/websocket-php](https://github.com/Textalk/websocket-php) and upgraded with non-blocking reads. Primary goal is to use only the client for non-blocking reads (server is largely left unchanged, but the state is not checked anymore). Non-blocking reads is enabled for client by default and returns empty message immediately. This feature is useful, if one has a threading mechanism already and just want to check for received messages.
## Websocket Client and Server for PHP
diff --git a/composer.json b/composer.json
index 23018ee..2b8725d 100644
--- a/composer.json
+++ b/composer.json
@@ -1,5 +1,5 @@
{
- "name": "textalk/websocket",
+ "name": "narenknn/websocket",
"description": "WebSocket client and server",
"license": "ISC",
"type": "library",
@@ -26,7 +26,7 @@
"phrity/net-uri": "^1.0",
"phrity/util-errorhandler": "^1.0",
"psr/log": "^1.0 | ^2.0 | ^3.0",
- "psr/http-message": "^1.0"
+ "psr/http-message": "^2.0"
},
"require-dev": {
"phpunit/phpunit": "^9.0",
diff --git a/lib/Client.php b/lib/Client.php
index 1f46ab1..ea71573 100644
--- a/lib/Client.php
+++ b/lib/Client.php
@@ -38,6 +38,7 @@ class Client implements LoggerAwareInterface
'persistent' => false,
'return_obj' => false,
'timeout' => 5,
+ 'blocking' => false,
];
private $socket_uri;
@@ -157,6 +158,14 @@ public function pong(string $payload = ''): void
$this->send($payload, 'pong');
}
+ /**
+ * Returns true when equal pongs received for pings.
+ */
+ public function pongs4pings(): bool
+ {
+ return $this->connection->pongs4pings();
+ }
+
/**
* Send message.
* @param string $payload Message to send.
@@ -233,7 +242,6 @@ public function receive()
return $return;
}
-
/* ---------- Connection functions ----------------------------------------------- */
/**
@@ -442,11 +450,11 @@ function ($key, $value) {
}
$keyAccept = trim($matches[1]);
- $expectedResonse = base64_encode(
+ $expectedResponse = base64_encode(
pack('H*', sha1($key . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'))
);
- if ($keyAccept !== $expectedResonse) {
+ if ($keyAccept !== $expectedResponse) {
$error = 'Server sent bad upgrade response.';
$this->logger->error($error);
throw new ConnectionException($error);
diff --git a/lib/Connection.php b/lib/Connection.php
index d5aa48b..553a7ef 100644
--- a/lib/Connection.php
+++ b/lib/Connection.php
@@ -28,6 +28,17 @@ class Connection implements LoggerAwareInterface
private $read_buffer;
private $msg_factory;
private $options = [];
+ private $pings = 0;
+ private $pongs = 0;
+ private $nbstat = [
+ 'state' => 0,
+ 'data' => '',
+ 'dlen' => 0,
+ 'data0' => '',
+ 'data1' => '',
+ 'data2' => '',
+ 'data3' => '',
+ ];
protected $is_closing = false;
protected $close_status = null;
@@ -42,6 +53,9 @@ public function __construct($stream, array $options = [])
$this->setOptions($options);
$this->setLogger(new NullLogger());
$this->msg_factory = new Factory();
+ if (false == $this->options['blocking']) {
+ stream_set_blocking($this->stream, $this->options['blocking']);
+ }
}
public function __destruct()
@@ -97,6 +111,9 @@ public function close(int $status = 1000, string $message = 'ttfn'): void
// Push a message to stream
public function pushMessage(Message $message, bool $masked = true): void
{
+ if ('ping' == $message->getOpcode()) { /* keep count */
+ $this->pings++;
+ }
$frames = $message->getFrames($masked, $this->options['fragment_size']);
foreach ($frames as $frame) {
$this->pushFrame($frame);
@@ -118,6 +135,8 @@ public function pullMessage(): Message
if ($opcode == 'close') {
$this->close();
+ } else if ('pong' == $opcode) {
+ $this->pongs++;
}
// Continuation and factual opcode
@@ -156,15 +175,24 @@ public function pullMessage(): Message
return $message;
}
-
/* ---------- Frame I/O methods -------------------------------------------------- */
// Pull frame from stream
private function pullFrame(): array
{
+ /* take care of non-blocking case */
+ $nbret = [true/*final*/, ''/*payload*/, 'text'/*opcode*/, false/*masked*/];
+
// Read the fragment "header" first, two bytes.
- $data = $this->read(2);
- list ($byte_1, $byte_2) = array_values(unpack('C*', $data));
+ if (0 == $this->nbstat['state']) {
+ $this->nbstat['data0'] = $this->read(2);
+ if (strlen($this->nbstat['data0']) < 2) {
+ return $nbret;
+ }
+ // print ("header:" . bin2hex($this->nbstat['data0']) . "\n");
+ $this->nbstat['state'] = 1;
+ }
+ list ($byte_1, $byte_2) = array_values(unpack('C*', $this->nbstat['data0']));
$final = (bool)($byte_1 & 0b10000000); // Final fragment marker.
$rsv = $byte_1 & 0b01110000; // Unused bits, ignore
@@ -186,33 +214,62 @@ private function pullFrame(): array
// Payload length
$payload_length = $byte_2 & 0b01111111;
- if ($payload_length > 125) {
- if ($payload_length === 126) {
- $data = $this->read(2); // 126: Payload is a 16-bit unsigned int
- $payload_length = current(unpack('n', $data));
- } else {
- $data = $this->read(8); // 127: Payload is a 64-bit unsigned int
- $payload_length = current(unpack('J', $data));
+ if (1 == $this->nbstat['state']) {
+ if ($payload_length > 125) {
+ if ($payload_length === 126) {
+ $this->nbstat['data1'] = $this->read(2); // 126: Payload is a 16-bit unsigned int
+ if (strlen($this->nbstat['data1']) < 2) {
+ return $nbret;
+ }
+ $payload_length = current(unpack('n', $this->nbstat['data1']));
+ } else {
+ $this->nbstat['data1'] = $this->read(8); // 127: Payload is a 64-bit unsigned int
+ if (strlen($this->nbstat['data1']) < 8) {
+ return $nbret;
+ }
+ $payload_length = current(unpack('J', $this->nbstat['data1']));
+ }
+ }
+ $this->nbstat['state'] = $masked ? 2 : 3;
+ } else { /* if entered again, I need to restore the $payload_length */
+ if ($payload_length > 125) {
+ if ($payload_length === 126) {
+ $payload_length = current(unpack('n', $this->nbstat['data1']));
+ } else {
+ $payload_length = current(unpack('J', $this->nbstat['data1']));
+ }
}
}
// Get masking key.
- if ($masked) {
- $masking_key = $this->read(4);
+ if (2 == $this->nbstat['state']) {
+ $this->nbstat['data2'] = $this->read(4);
+ if (strlen($this->nbstat['data2']) < 2) {
+ return $nbret;
+ }
+ $this->nbstat['state'] = 3;
}
+ $masking_key = $this->nbstat['data2'];
// Get the actual payload, if any (might not be for e.g. close frames.
- if ($payload_length > 0) {
- $data = $this->read($payload_length);
+ if (3 == $this->nbstat['state']) {
+ if ($payload_length > 0) {
+ $this->nbstat['data3'] .= $this->read($payload_length - strlen($this->nbstat['data3']));
+ if (strlen($this->nbstat['data3']) < $payload_length) {
+ return $nbret;
+ }
- if ($masked) {
- // Unmask payload.
- for ($i = 0; $i < $payload_length; $i++) {
- $payload .= ($data[$i] ^ $masking_key[$i % 4]);
+ if ($masked) {
+ // Unmask payload.
+ for ($i = 0; $i < $payload_length; $i++) {
+ $payload .= ($this->nbstat['data3'][$i] ^ $masking_key[$i % 4]);
+ }
+ } else {
+ $payload = $this->nbstat['data3'];
}
- } else {
- $payload = $data;
}
+ $this->nbstat['state'] = 0;
+ $this->nbstat['data3'] = '';
}
$this->logger->debug("[connection] Pulled '{opcode}' frame", [
@@ -263,6 +320,8 @@ private function pushFrame(array $frame): void
$data .= $payload;
}
+ // print("pushFrame:" . $frame[2] . " final:" . ($frame[0] ? "true" : "false") . " len:" . strlen($frame[1]) . " datalen:" . strlen($data) . " data:" . bin2hex($data) . "\n");
+
$this->write($data);
$this->logger->debug("[connection] Pushed '{$opcode}' frame", [
@@ -437,22 +496,71 @@ public function getLine(int $length, string $ending): string
*/
public function gets(int $length): string
{
+ stream_set_blocking($this->stream, true);
+
$line = fgets($this->stream, $length);
if ($line === false) {
$this->throwException('Could not read from stream');
}
$read = strlen($line);
$this->logger->debug("Read {$read} bytes of line.");
+
+ stream_set_blocking($this->stream, $this->options['blocking']);
+
return $line;
}
+ /**
+ * Non-blocking Read characters from stream.
+ * @param int $length Maximum number of bytes to read
+ * @return string when full data was read else will return an empty string
+ */
+ private function nbread(int $len = 1): string
+ {
+ $data = '';
+ if ($this->nbstat['dlen'] < $len) {
+ $rdat = fread($this->stream, $len - $this->nbstat['dlen']);
+ if ($rdat) {
+ $this->nbstat['data'] .= $rdat;
+ $this->nbstat['dlen'] += strlen($rdat);
+ } else {
+ $meta = stream_get_meta_data($this->stream);
+ if (!empty($meta['timed_out'])) {
+ $message = 'Client read timeout';
+ $this->logger->error($message, $meta);
+ throw new TimeoutException($message, ConnectionException::TIMED_OUT, $meta);
+ }
+ }
+ }
+ if ($this->nbstat['dlen'] < $len)
+ return $data;
+ /* success, obtained required data */
+ $data = $this->nbstat['data'];
+ $this->nbstat['data'] = '';
+ $this->nbstat['dlen'] = 0;
+ return $data;
+ }
+
+ /**
+ * Returns true when equal pongs received for pings
+ */
+ public function pongs4pings(): bool
+ {
+ return ($this->pings > 0) && ($this->pings == $this->pongs);
+ }
+
/**
* Read characters from stream.
* @param int $length Maximum number of bytes to read
* @return string Read data
*/
- public function read(string $length): string
+ public function read(int $length): string
{
+ /* non-blocking */
+ if (false == $this->options['blocking']) {
+ return $this->nbread($length);
+ }
+ /* blocking */
$data = '';
while (strlen($data) < $length) {
$buffer = fread($this->stream, $length - strlen($data));
@@ -475,6 +583,7 @@ public function read(string $length): string
$read = strlen($data);
$this->logger->debug("Read {$read} of {$length} bytes.");
}
+
return $data;
}
@@ -485,7 +594,19 @@ public function read(string $length): string
public function write(string $data): void
{
$length = strlen($data);
- $written = fwrite($this->stream, $data);
+ $loopcnt = 0;
+ do {
+ $length = strlen($data);
+ $written = fwrite($this->stream, $data);
+ if ((false == $this->options['blocking']) && ($written < $length)) {
+ /* when non-blocking, less data may be written, try few more times */
+ usleep(1000);
+ $data = substr($data, -($length-$written));
+ $loopcnt++;
+ // if ($loopcnt > 20) break;
+ continue;
+ }
+ } while ($written < $length);
if ($written === false) {
$this->throwException("Failed to write {$length} bytes.");
}
diff --git a/lib/Server.php b/lib/Server.php
index 1521588..9bce1bf 100644
--- a/lib/Server.php
+++ b/lib/Server.php
@@ -34,6 +34,7 @@ class Server implements LoggerAwareInterface
'port' => 8000,
'return_obj' => false,
'timeout' => null,
+ 'blocking' => true,
];
protected $port;
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..3404e68
--- /dev/null
+++ b/package.json
@@ -0,0 +1,6 @@
+{
+ "devDependencies": {
+ "websocket": "^1.0.35",
+ "ws": "^8.19.0"
+ }
+}
diff --git a/phpunit-integration.xml b/phpunit-integration.xml
new file mode 100644
index 0000000..780727c
--- /dev/null
+++ b/phpunit-integration.xml
@@ -0,0 +1,8 @@
+
+
+
+
+ tests/ClientIntegrationTest.php
+
+
+
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index 60aa5bb..fe122be 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -8,6 +8,8 @@
tests
+ tests/ClientIntegrationTest.php
+ tests/bin
diff --git a/tests/ClientIntegrationTest.php b/tests/ClientIntegrationTest.php
new file mode 100644
index 0000000..855300c
--- /dev/null
+++ b/tests/ClientIntegrationTest.php
@@ -0,0 +1,449 @@
+ self::TIMEOUT, 'blocking' => true]);
+ // Trigger connection by sending a message
+ $client->send('test');
+ $this->assertTrue($client->isConnected(), 'Failed to connect to ws.postman-echo.com');
+ $client->close();
+ }
+
+ public function testPostmanEchoSendTextMessage(): void
+ {
+ $client = new Client(self::SERVER_POSTMAN, ['timeout' => self::TIMEOUT, 'blocking' => true]);
+ $message = 'Test message for Postman';
+ $client->send($message);
+ $response = $client->receive();
+ $this->assertEquals($message, $response);
+ $client->close();
+ }
+
+ public function testPostmanEchoSendBinaryMessage(): void
+ {
+ $this->markTestSkipped('Seems like servers are not supporting echo binary data..');
+ $client = new Client(self::SERVER_POSTMAN, ['timeout' => self::TIMEOUT, 'blocking' => true]);
+ $message = '';
+ for ($i = 1; $i < 500; $i++) {
+ $message .= chr($i % 256);
+ }
+ $client->send($message, 'binary', false);
+ $response = $client->receive();
+ // print (strlen($message) . " == " . strlen($response) . "\n");
+ $this->assertEquals($message, $response);
+ $client->close();
+ }
+
+ public function testPostmanEchoMultipleMessages(): void
+ {
+ $client = new Client(self::SERVER_POSTMAN, ['timeout' => self::TIMEOUT, 'blocking' => true]);
+
+ $messages = ['One', 'Two', 'Three', 'Four', 'Five'];
+ foreach ($messages as $message) {
+ $client->send($message);
+ $response = $client->receive();
+ $this->assertEquals($message, $response);
+ }
+
+ $client->close();
+ }
+
+ public function testPostmanEchoPingPong(): void
+ {
+ $client = new Client(self::SERVER_POSTMAN, ['timeout' => self::TIMEOUT, 'blocking' => true]);
+ // Note: Echo server may not respond to ping, but should not error
+ $client->send('ping-test');
+ $response = $client->receive();
+ $this->assertNotEmpty($response);
+ $client->close();
+ }
+
+ public function testPostmanEchoLargeMessage(): void
+ {
+ $client = new Client(self::SERVER_POSTMAN, ['timeout' => self::TIMEOUT, 'blocking' => true]);
+ $message = str_repeat('TestData-', 2000);
+ $client->send($message, 'text', false);
+ $response = $client->receive();
+ $this->assertEquals($message, $response);
+ $client->close();
+ }
+
+ public function testPostmanEchoConnectionClose(): void
+ {
+ $client = new Client(self::SERVER_POSTMAN, ['timeout' => self::TIMEOUT, 'blocking' => true]);
+ $client->send('trigger connection');
+
+ $this->assertTrue($client->isConnected());
+
+ $client->close(1000, 'Test complete');
+
+ $this->assertFalse($client->isConnected());
+ $this->assertEquals(1000, $client->getCloseStatus());
+ }
+
+ // =========================================================================
+ // Binance Stream Tests
+ // =========================================================================
+
+ public function testBinanceStreamConnect(): void
+ {
+ $client = new Client(self::SERVER_BINANCE, ['timeout' => self::TIMEOUT, 'blocking' => true]);
+ // Trigger connection
+ $client->send('');
+ $this->assertTrue($client->isConnected(), 'Failed to connect to Binance stream');
+ $client->close();
+ }
+
+ public function testBinanceStreamReceiveRealTimeData(): void
+ {
+ $client = new Client(self::SERVER_BINANCE, ['timeout' => self::TIMEOUT, 'blocking' => true]);
+
+ // Wait for at least one message
+ $received = false;
+ $attempts = 0;
+ $maxAttempts = 10;
+
+ while (!$received && $attempts < $maxAttempts) {
+ try {
+ $response = $client->receive();
+ if (!empty($response)) {
+ $data = json_decode($response, true);
+ $this->assertNotNull($data, 'Response is not valid JSON');
+ $this->assertArrayHasKey('e', $data, 'Missing event type field');
+ $received = true;
+ }
+ } catch (\Exception $e) {
+ $attempts++;
+ usleep(100000); // 100ms
+ }
+ }
+
+ $this->assertTrue($received, 'Did not receive any data from Binance stream');
+ $client->close();
+ }
+
+ public function testBinanceStreamReceiveMultipleMessages(): void
+ {
+ $client = new Client(self::SERVER_BINANCE, ['timeout' => self::TIMEOUT, 'blocking' => true]);
+
+ $messages = [];
+ $targetMessages = 3;
+
+ $startTime = time();
+ while (count($messages) < $targetMessages && (time() - $startTime) < 10) {
+ try {
+ $response = $client->receive();
+ if (!empty($response)) {
+ $data = json_decode($response, true);
+ if ($data !== null) {
+ $messages[] = $data;
+ }
+ }
+ } catch (\Exception $e) {
+ // Continue trying
+ }
+ }
+
+ $this->assertGreaterThanOrEqual(1, count($messages), 'Did not receive enough messages from Binance');
+ $client->close();
+ }
+
+ public function testBinanceStreamConnectionClose(): void
+ {
+ $client = new Client(self::SERVER_BINANCE, ['timeout' => self::TIMEOUT, 'blocking' => true]);
+ $client->send('');
+
+ $this->assertTrue($client->isConnected());
+
+ $client->close(1000, 'Done testing');
+
+ $this->assertFalse($client->isConnected());
+ }
+
+ // =========================================================================
+ // Non-Blocking Mode Tests
+ // =========================================================================
+
+ public function testNonBlockingSendAndReceive(): void
+ {
+ $client = new Client(self::SERVER_POSTMAN, ['timeout' => self::TIMEOUT, 'blocking' => false]);
+ $message = 'Test message for Postman';
+ $client->send($message);
+ $response = '';
+ for ($i=0; $i<10; $i++) {
+ usleep(100000);
+ $response .= $client->receive();
+ }
+ $this->assertEquals($message, $response);
+ $client->close();
+ }
+
+ public function testNonBlockingMultipleMessages(): void
+ {
+ $client = new Client(self::SERVER_POSTMAN, ['timeout' => self::TIMEOUT, 'blocking' => false]);
+
+ $messages = ['One', 'Two', 'Three', 'Four', 'Five'];
+ foreach ($messages as $message) {
+ $client->send($message);
+ $response = '';
+ for ($i=0; $i<10; $i++) {
+ usleep(100000);
+ $response .= $client->receive();
+ }
+ $this->assertEquals($message, $response);
+ }
+
+ $client->close();
+ }
+
+ public function testNonBlockingLargeMessage(): void
+ {
+ $client = new Client(self::SERVER_POSTMAN, ['timeout' => self::TIMEOUT, 'blocking' => false]);
+ $message = str_repeat('TestData-', 2000);
+ $client->send($message, 'text', false);
+ $response = '';
+ for ($i=0; $i<10; $i++) {
+ usleep(100000);
+ $response .= $client->receive();
+ }
+ $this->assertEquals($message, $response);
+ $client->close();
+ }
+
+ public function testNonBlockingConnectionClose(): void
+ {
+ $client = new Client(self::SERVER_POSTMAN, ['timeout' => self::TIMEOUT, 'blocking' => false]);
+ $client->send('trigger connection');
+
+ $this->assertTrue($client->isConnected());
+
+ $client->close(1000, 'Test complete');
+
+ $this->assertFalse($client->isConnected());
+ $this->assertEquals(1000, $client->getCloseStatus());
+ }
+
+ // =========================================================================
+ // Large Variable Size Tests
+ // =========================================================================
+
+ public function testLargeMessage10KB(): void
+ {
+ $client = new Client(self::SERVER_POSTMAN, ['timeout' => self::TIMEOUT, 'blocking' => false]);
+ $message = str_repeat('0123456789', 1024);
+ $client->send($message, 'text', false);
+ $response = '';
+ for ($i = 0; $i < 10; $i++) {
+ usleep(100000);
+ $response .= $client->receive();
+ }
+ $this->assertEquals($message, $response);
+ $client->close();
+ }
+
+ public function testLargeMessage100KB(): void
+ {
+ $client = new Client(self::SERVER_POSTMAN, ['timeout' => self::TIMEOUT, 'blocking' => false]);
+ $message = str_repeat('ABCDEFGHIJ', 10240);
+ $client->send($message, 'text', false);
+ $response = '';
+ for ($i = 0; $i < 15; $i++) {
+ usleep(100000);
+ $response .= $client->receive();
+ }
+ $this->assertEquals($message, $response);
+ $client->close();
+ }
+
+ public function testLargeMessage1MB(): void
+ {
+ $client = new Client(self::SERVER_LOCAL, ['timeout' => 30*3, 'blocking' => false]);
+ $message = str_repeat('LargePayload-', 65536);
+ $client->send($message, 'text', false);
+ $response = '';
+ for ($i = 0; $i < 25; $i++) {
+ usleep(100000);
+ $response .= $client->receive();
+ }
+ $this->assertEquals($message, $response);
+ $client->close();
+ }
+
+ // =========================================================================
+ // Multiple Large Messages Tests
+ // =========================================================================
+
+ public function testNonBlockingMultipleLargeMessages(): void
+ {
+ $client = new Client(self::SERVER_POSTMAN, ['timeout' => self::TIMEOUT, 'blocking' => false]);
+
+ $messages = [
+ str_repeat('First-', 1000),
+ str_repeat('Second-', 2000),
+ str_repeat('Third-', 3000),
+ ];
+
+ foreach ($messages as $message) {
+ $client->send($message, 'text', false);
+ $response = '';
+ for ($i = 0; $i < 15; $i++) {
+ usleep(100000);
+ $response .= $client->receive();
+ }
+ $this->assertEquals($message, $response);
+ }
+
+ $client->close();
+ }
+
+ public function testMultipleLargeMessages(): void
+ {
+ $client = new Client(self::SERVER_POSTMAN, ['timeout' => self::TIMEOUT, 'blocking' => false]);
+
+ $messages = [
+ str_repeat('Alpha-', 1000),
+ str_repeat('Beta-', 2000),
+ str_repeat('Gamma-', 3000),
+ ];
+
+ foreach ($messages as $message) {
+ $client->send($message, 'text', false);
+ $response = '';
+ for ($i = 0; $i < 15; $i++) {
+ usleep(100000);
+ $response .= $client->receive();
+ }
+ $this->assertEquals($message, $response);
+ }
+
+ $client->close();
+ }
+
+ // =========================================================================
+ // Large Message Edge Cases
+ // =========================================================================
+
+ public function testLargeBinaryMessage(): void
+ {
+ $this->markTestSkipped('Seems like servers are not supporting echo binary data..');
+ $client = new Client(self::SERVER_WEBSOCKET, ['timeout' => self::TIMEOUT, 'blocking' => false]);
+ $message = '';
+ for ($i = 0; $i < 5000; $i++) {
+ $message .= chr($i % 256);
+ }
+ $client->send($message, 'binary', false);
+ $response = '';
+ for ($i = 0; $i < 10; $i++) {
+ usleep(100000);
+ $response .= $client->receive();
+ }
+ // print (strlen($message) . " == " . strlen($response) . "\n");
+ $this->assertEquals($message, $response);
+ $client->close();
+ }
+
+ public function testLargeUnicodeMessage(): void
+ {
+ $client = new Client(self::SERVER_POSTMAN, ['timeout' => self::TIMEOUT, 'blocking' => false]);
+ $message = str_repeat('こんにちは世界🌍', 500);
+ $client->send($message, 'text', false);
+ $response = '';
+ for ($i = 0; $i < 10; $i++) {
+ usleep(100000);
+ $response .= $client->receive();
+ }
+ $this->assertEquals($message, $response);
+ $client->close();
+ }
+
+ public function testLargeMessageWithNewlines(): void
+ {
+ $client = new Client(self::SERVER_POSTMAN, ['timeout' => self::TIMEOUT, 'blocking' => false]);
+ $message = str_repeat("Line1\tTabbed\r\nLine2\tTabbed\r\n", 500);
+ $client->send($message, 'text', false);
+ $response = '';
+ for ($i = 0; $i < 10; $i++) {
+ usleep(100000);
+ $response .= $client->receive();
+ }
+ $this->assertEquals($message, $response);
+ $client->close();
+ }
+
+ // =========================================================================
+ // Ping/Pong Tests
+ // =========================================================================
+ public function testPingPongSingle(): void
+ {
+ $client = new Client(self::SERVER_POSTMAN, ['timeout' => self::TIMEOUT, 'blocking' => false]);
+ $client->send('trigger');
+ $this->assertFalse($client->pongs4pings(), 'Check initial state');
+ $client->ping();
+
+ $rmsg = '';
+ for ($i=0; $i<10; $i++) {
+ usleep(100000);
+ $rmsg .= $client->receive();
+ }
+
+ $this->assertEquals('trigger', $rmsg);
+ $this->assertTrue($client->pongs4pings(), 'Pong not received for ping');
+ $client->close();
+ }
+
+ public function testPingPongMultipleRandom(): void
+ {
+ $client = new Client(self::SERVER_POSTMAN, ['timeout' => self::TIMEOUT, 'blocking' => false]);
+
+ $client->send('trigger');
+ $this->assertFalse($client->pongs4pings(), 'Check initial state');
+
+ $pingCount = 5;
+ for ($i = 0; $i < $pingCount; $i++) {
+ $client->ping('');
+ usleep(100000);
+ }
+
+ $rmsg = '';
+ for ($i=0; $i<10; $i++) {
+ usleep(100000);
+ $rmsg .= $client->receive();
+ }
+
+ $this->assertEquals('trigger', $rmsg);
+ $this->assertTrue($client->pongs4pings(), 'Pong not received for ping');
+ $client->close();
+ }
+}
diff --git a/tests/ClientTest.php b/tests/ClientTest.php
index df23d75..70aa1e6 100644
--- a/tests/ClientTest.php
+++ b/tests/ClientTest.php
@@ -427,8 +427,8 @@ public function testFailedWrite(): void
public function testBrokenRead(): void
{
- MockSocket::initialize('client.connect', $this);
- $client = new Client('ws://localhost:8000/my/mock/path');
+ MockSocket::initialize('client.connect-blocking', $this);
+ $client = new Client('ws://localhost:8000/my/mock/path', ['blocking' => true]);
$client->send('Connect');
MockSocket::initialize('receive-broken-read', $this);
$this->expectException('WebSocket\ConnectionException');
@@ -461,8 +461,8 @@ public function testReadTimeout(): void
public function testEmptyRead(): void
{
- MockSocket::initialize('client.connect', $this);
- $client = new Client('ws://localhost:8000/my/mock/path');
+ MockSocket::initialize('client.connect-blocking', $this);
+ $client = new Client('ws://localhost:8000/my/mock/path', ['blocking' => true]);
$client->send('Connect');
MockSocket::initialize('receive-empty-read', $this);
$this->expectException('WebSocket\TimeoutException');
diff --git a/tests/bin/echoserver.js b/tests/bin/echoserver.js
new file mode 100644
index 0000000..685beea
--- /dev/null
+++ b/tests/bin/echoserver.js
@@ -0,0 +1,29 @@
+// Source - https://stackoverflow.com/a/66789406
+// Posted by Derzu, modified by community. See post 'Timeline' for change history
+// Retrieved 2026-03-03, License - CC BY-SA 4.0
+
+// https://stackoverflow.com/questions/21797299/convert-base64-string-to-arraybuffer
+function base64ToArrayBuffer(base64) {
+ var binary_string = Buffer.from(base64, 'base64').toString('binary');
+ var len = binary_string.length;
+ var bytes = new Uint8Array(len);
+ for (var i = 0; i < len; i++) {
+ bytes[i] = binary_string.charCodeAt(i);
+ }
+
+ return bytes.buffer;
+}
+
+// websocket server
+const WebSocket = require("ws"); // websocket server
+const wss = new WebSocket.Server({ port: 9999 });
+console.log("WebSocket Server Started on port 9999");
+
+wss.binaryType = 'arraybuffer';
+const content_base64 = "c3RyZWFtIGV2ZW50"; // Place your base64 content here.
+const binaryData = base64ToArrayBuffer(content_base64);
+
+wss.on("connection", (ws) => {
+ console.log("WebSocket sending msg");
+ ws.send(binaryData);
+});
diff --git a/tests/bin/echoserver.php b/tests/bin/echoserver.php
new file mode 100644
index 0000000..369f3c2
--- /dev/null
+++ b/tests/bin/echoserver.php
@@ -0,0 +1,12 @@
+route('/', new Ratchet\Server\EchoServer, array('*'));
+$app->run();
diff --git a/tests/bin/echoserver.py b/tests/bin/echoserver.py
new file mode 100644
index 0000000..bc767e5
--- /dev/null
+++ b/tests/bin/echoserver.py
@@ -0,0 +1,22 @@
+#!/usr/bin/env python
+
+"""Echo server using the asyncio API."""
+
+import asyncio
+from websockets.asyncio.server import serve
+
+
+async def echo(websocket):
+ async for message in websocket:
+ # print ("debug message:", message);
+ print ("obtained:", ":".join("{:02x}".format(ord(c)) for c in message));
+ await websocket.send(message);
+
+
+async def main():
+ async with serve(echo, "127.0.0.1", 9999) as server:
+ await server.serve_forever();
+
+
+if __name__ == "__main__":
+ asyncio.run(main());
diff --git a/tests/bootstrap-integration.php b/tests/bootstrap-integration.php
new file mode 100644
index 0000000..8b9769a
--- /dev/null
+++ b/tests/bootstrap-integration.php
@@ -0,0 +1,7 @@
+interpolate($message, $context);
$context_string = empty($context) ? '' : json_encode($context);
diff --git a/tests/mock/MockSocket.php b/tests/mock/MockSocket.php
index e96806b..50481f5 100644
--- a/tests/mock/MockSocket.php
+++ b/tests/mock/MockSocket.php
@@ -16,8 +16,19 @@ class MockSocket
public static function handle($function, $params = [])
{
$current = array_shift(self::$queue);
- if ($function == 'get_resource_type' && is_null($current)) {
- return null; // Catch destructors
+ $null_handled_functions = [
+ 'get_resource_type' => null,
+ 'stream_get_meta_data' => ['eof' => false, 'timed_out' => false],
+ 'stream_set_blocking' => null,
+ 'feof' => false,
+ 'fclose' => true,
+ 'ftell' => 0,
+ ];
+ if (is_null($current) && isset($null_handled_functions[$function])) {
+ return $null_handled_functions[$function]; // Catch cleanup/destructor calls
+ }
+ if (is_null($current)) {
+ return null; // Ignore unexpected calls when queue is empty
}
self::$asserter->assertEquals($current['function'], $function);
foreach ($current['params'] as $index => $param) {
diff --git a/tests/mock/mock-socket.php b/tests/mock/mock-socket.php
index a038933..09304c1 100644
--- a/tests/mock/mock-socket.php
+++ b/tests/mock/mock-socket.php
@@ -71,6 +71,11 @@ function stream_socket_client($remote_socket, &$errno, &$errstr, $timeout, $flag
$args = [$remote_socket, $errno, $errstr, $timeout, $flags, $context];
return MockSocket::handle('stream_socket_client', $args);
}
+function stream_set_blocking($remote_socket, $blocking)
+{
+ $args = [$remote_socket, $blocking];
+ return MockSocket::handle('stream_set_blocking', $args);
+}
function get_resource_type()
{
$args = func_get_args();
diff --git a/tests/scripts/client.connect-authed.json b/tests/scripts/client.connect-authed.json
index b89ca53..497d59b 100644
--- a/tests/scripts/client.connect-authed.json
+++ b/tests/scripts/client.connect-authed.json
@@ -16,6 +16,14 @@
],
"return": "@mock-stream"
},
+ {
+ "function": "stream_set_blocking",
+ "params": [
+ "@mock-stream",
+ false
+ ],
+ "return": "@mock-stream"
+ },
{
"function": "get_resource_type",
"params": [
@@ -55,4 +63,4 @@
],
"return": 13
}
-]
\ No newline at end of file
+]
diff --git a/tests/scripts/client.connect-bad-stream.json b/tests/scripts/client.connect-bad-stream.json
index aecf0fb..b6571cd 100644
--- a/tests/scripts/client.connect-bad-stream.json
+++ b/tests/scripts/client.connect-bad-stream.json
@@ -16,6 +16,14 @@
],
"return": "@mock-stream"
},
+ {
+ "function": "stream_set_blocking",
+ "params": [
+ "@mock-stream",
+ false
+ ],
+ "return": "@mock-stream"
+ },
{
"function": "get_resource_type",
"params": [
@@ -23,4 +31,4 @@
],
"return": "bad stream"
}
-]
\ No newline at end of file
+]
diff --git a/tests/scripts/client.connect-blocking.json b/tests/scripts/client.connect-blocking.json
new file mode 100644
index 0000000..3030d15
--- /dev/null
+++ b/tests/scripts/client.connect-blocking.json
@@ -0,0 +1,60 @@
+[
+ {
+ "function": "stream_context_create",
+ "params": [],
+ "return": "@mock-stream-context"
+ },
+ {
+ "function": "stream_socket_client",
+ "params": [
+ "tcp:\/\/localhost:8000",
+ null,
+ null,
+ 5,
+ 4,
+ "@mock-stream-context"
+ ],
+ "return": "@mock-stream"
+ },
+ {
+ "function": "get_resource_type",
+ "params": [
+ "@mock-stream"
+ ],
+ "return": "stream"
+ },
+ {
+ "function": "stream_set_timeout",
+ "params": [
+ "@mock-stream",
+ 5
+ ],
+ "return": true
+ },
+ {
+ "function": "fwrite",
+ "regexp": true,
+ "params": [
+ "@mock-stream",
+ "GET /my/mock/path HTTP/1.1\r\nHost: localhost:8000\r\nUser-Agent: websocket-client-php\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n"
+ ],
+ "input-op": "key-save",
+ "return": 199
+ },
+ {
+ "function": "fgets",
+ "params": [
+ "@mock-stream",
+ 1024
+ ],
+ "return-op": "key-respond",
+ "return": "HTTP\/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {key}\r\n\r\n"
+ },
+ {
+ "function": "fwrite",
+ "params": [
+ "@mock-stream"
+ ],
+ "return": 13
+ }
+]
diff --git a/tests/scripts/client.connect-context.json b/tests/scripts/client.connect-context.json
index 02ad443..1d939ff 100644
--- a/tests/scripts/client.connect-context.json
+++ b/tests/scripts/client.connect-context.json
@@ -16,6 +16,14 @@
],
"return": "@mock-stream"
},
+ {
+ "function": "stream_set_blocking",
+ "params": [
+ "@mock-stream",
+ false
+ ],
+ "return": "@mock-stream"
+ },
{
"function": "get_resource_type",
"params": [
@@ -55,4 +63,4 @@
],
"return": 13
}
-]
\ No newline at end of file
+]
diff --git a/tests/scripts/client.connect-default-port-ws.json b/tests/scripts/client.connect-default-port-ws.json
index 4fbd6b0..20be748 100644
--- a/tests/scripts/client.connect-default-port-ws.json
+++ b/tests/scripts/client.connect-default-port-ws.json
@@ -16,6 +16,14 @@
],
"return": "@mock-stream"
},
+ {
+ "function": "stream_set_blocking",
+ "params": [
+ "@mock-stream",
+ false
+ ],
+ "return": "@mock-stream"
+ },
{
"function": "get_resource_type",
"params": [
@@ -56,4 +64,4 @@
],
"return": 13
}
-]
\ No newline at end of file
+]
diff --git a/tests/scripts/client.connect-default-port-wss.json b/tests/scripts/client.connect-default-port-wss.json
index c17c9cd..72c2d38 100644
--- a/tests/scripts/client.connect-default-port-wss.json
+++ b/tests/scripts/client.connect-default-port-wss.json
@@ -16,6 +16,14 @@
],
"return": "@mock-stream"
},
+ {
+ "function": "stream_set_blocking",
+ "params": [
+ "@mock-stream",
+ false
+ ],
+ "return": "@mock-stream"
+ },
{
"function": "get_resource_type",
"params": [
@@ -56,4 +64,4 @@
],
"return": 13
}
-]
\ No newline at end of file
+]
diff --git a/tests/scripts/client.connect-error.json b/tests/scripts/client.connect-error.json
index e6c523d..7dc2b2f 100644
--- a/tests/scripts/client.connect-error.json
+++ b/tests/scripts/client.connect-error.json
@@ -20,4 +20,4 @@
},
"return": false
}
-]
\ No newline at end of file
+]
diff --git a/tests/scripts/client.connect-extended.json b/tests/scripts/client.connect-extended.json
index 2313344..b54d1ac 100644
--- a/tests/scripts/client.connect-extended.json
+++ b/tests/scripts/client.connect-extended.json
@@ -16,6 +16,14 @@
],
"return": "@mock-stream"
},
+ {
+ "function": "stream_set_blocking",
+ "params": [
+ "@mock-stream",
+ false
+ ],
+ "return": "@mock-stream"
+ },
{
"function": "get_resource_type",
"params": [
@@ -56,4 +64,4 @@
],
"return": 13
}
-]
\ No newline at end of file
+]
diff --git a/tests/scripts/client.connect-handshake-error.json b/tests/scripts/client.connect-handshake-error.json
index 3eedef6..287430a 100644
--- a/tests/scripts/client.connect-handshake-error.json
+++ b/tests/scripts/client.connect-handshake-error.json
@@ -16,6 +16,14 @@
],
"return": "@mock-stream"
},
+ {
+ "function": "stream_set_blocking",
+ "params": [
+ "@mock-stream",
+ false
+ ],
+ "return": "@mock-stream"
+ },
{
"function": "get_resource_type",
"params": [
@@ -83,4 +91,4 @@
],
"return": ""
}
-]
\ No newline at end of file
+]
diff --git a/tests/scripts/client.connect-handshake-failure.json b/tests/scripts/client.connect-handshake-failure.json
index 80ae018..082423c 100644
--- a/tests/scripts/client.connect-handshake-failure.json
+++ b/tests/scripts/client.connect-handshake-failure.json
@@ -16,6 +16,14 @@
],
"return": "@mock-stream"
},
+ {
+ "function": "stream_set_blocking",
+ "params": [
+ "@mock-stream",
+ false
+ ],
+ "return": "@mock-stream"
+ },
{
"function": "get_resource_type",
"params": [
@@ -47,4 +55,4 @@
],
"return": false
}
-]
\ No newline at end of file
+]
diff --git a/tests/scripts/client.connect-headers.json b/tests/scripts/client.connect-headers.json
index 5657c43..b60c30e 100644
--- a/tests/scripts/client.connect-headers.json
+++ b/tests/scripts/client.connect-headers.json
@@ -16,6 +16,14 @@
],
"return": "@mock-stream"
},
+ {
+ "function": "stream_set_blocking",
+ "params": [
+ "@mock-stream",
+ false
+ ],
+ "return": "@mock-stream"
+ },
{
"function": "get_resource_type",
"params": [
@@ -64,4 +72,4 @@
],
"return": 13
}
-]
\ No newline at end of file
+]
diff --git a/tests/scripts/client.connect-invalid-key.json b/tests/scripts/client.connect-invalid-key.json
index 4f71a32..aeb9cc8 100644
--- a/tests/scripts/client.connect-invalid-key.json
+++ b/tests/scripts/client.connect-invalid-key.json
@@ -16,6 +16,14 @@
],
"return": "@mock-stream"
},
+ {
+ "function": "stream_set_blocking",
+ "params": [
+ "@mock-stream",
+ false
+ ],
+ "return": "@mock-stream"
+ },
{
"function": "get_resource_type",
"params": [
@@ -46,4 +54,4 @@
],
"return": "HTTP\/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: BAD\r\n\r\n"
}
-]
\ No newline at end of file
+]
diff --git a/tests/scripts/client.connect-invalid-upgrade.json b/tests/scripts/client.connect-invalid-upgrade.json
index a2b8455..2b6a7fc 100644
--- a/tests/scripts/client.connect-invalid-upgrade.json
+++ b/tests/scripts/client.connect-invalid-upgrade.json
@@ -16,6 +16,14 @@
],
"return": "@mock-stream"
},
+ {
+ "function": "stream_set_blocking",
+ "params": [
+ "@mock-stream",
+ false
+ ],
+ "return": "@mock-stream"
+ },
{
"function": "get_resource_type",
"params": [
@@ -46,4 +54,4 @@
],
"return": "Invalid upgrade response\r\n\r\n"
}
-]
\ No newline at end of file
+]
diff --git a/tests/scripts/client.connect-persistent-failure.json b/tests/scripts/client.connect-persistent-failure.json
index 337377d..87f067c 100644
--- a/tests/scripts/client.connect-persistent-failure.json
+++ b/tests/scripts/client.connect-persistent-failure.json
@@ -16,6 +16,14 @@
],
"return": "@mock-stream"
},
+ {
+ "function": "stream_set_blocking",
+ "params": [
+ "@mock-stream",
+ false
+ ],
+ "return": "@mock-stream"
+ },
{
"function": "get_resource_type",
"params": [
diff --git a/tests/scripts/client.connect-persistent.json b/tests/scripts/client.connect-persistent.json
index 7e9c154..4312136 100644
--- a/tests/scripts/client.connect-persistent.json
+++ b/tests/scripts/client.connect-persistent.json
@@ -16,6 +16,14 @@
],
"return": "@mock-stream"
},
+ {
+ "function": "stream_set_blocking",
+ "params": [
+ "@mock-stream",
+ false
+ ],
+ "return": "@mock-stream"
+ },
{
"function": "get_resource_type",
"params": [
diff --git a/tests/scripts/client.connect-root.json b/tests/scripts/client.connect-root.json
index 4eed902..fe72ece 100644
--- a/tests/scripts/client.connect-root.json
+++ b/tests/scripts/client.connect-root.json
@@ -16,6 +16,14 @@
],
"return": "@mock-stream"
},
+ {
+ "function": "stream_set_blocking",
+ "params": [
+ "@mock-stream",
+ false
+ ],
+ "return": "@mock-stream"
+ },
{
"function": "get_resource_type",
"params": [
@@ -56,4 +64,4 @@
],
"return": 13
}
-]
\ No newline at end of file
+]
diff --git a/tests/scripts/client.connect-timeout.json b/tests/scripts/client.connect-timeout.json
index 9a2c38a..1912b2b 100644
--- a/tests/scripts/client.connect-timeout.json
+++ b/tests/scripts/client.connect-timeout.json
@@ -16,6 +16,14 @@
],
"return": "@mock-stream"
},
+ {
+ "function": "stream_set_blocking",
+ "params": [
+ "@mock-stream",
+ false
+ ],
+ "return": "@mock-stream"
+ },
{
"function": "get_resource_type",
"params": [
@@ -55,4 +63,4 @@
],
"return": 13
}
-]
\ No newline at end of file
+]
diff --git a/tests/scripts/client.connect.json b/tests/scripts/client.connect.json
index 41b8c62..06b392b 100644
--- a/tests/scripts/client.connect.json
+++ b/tests/scripts/client.connect.json
@@ -16,6 +16,14 @@
],
"return": "@mock-stream"
},
+ {
+ "function": "stream_set_blocking",
+ "params": [
+ "@mock-stream",
+ false
+ ],
+ "return": "@mock-stream"
+ },
{
"function": "get_resource_type",
"params": [
@@ -57,4 +65,4 @@
],
"return": 13
}
-]
\ No newline at end of file
+]
diff --git a/tests/scripts/client.reconnect.json b/tests/scripts/client.reconnect.json
index cfd9621..8adad3a 100644
--- a/tests/scripts/client.reconnect.json
+++ b/tests/scripts/client.reconnect.json
@@ -30,6 +30,14 @@
],
"return": "@mock-stream"
},
+ {
+ "function": "stream_set_blocking",
+ "params": [
+ "@mock-stream",
+ false
+ ],
+ "return": "@mock-stream"
+ },
{
"function": "get_resource_type",
"params": [
@@ -96,4 +104,4 @@
],
"return": "stream"
}
-]
\ No newline at end of file
+]
diff --git a/tests/scripts/server.accept-timeout.json b/tests/scripts/server.accept-timeout.json
index 17a5660..03680d9 100644
--- a/tests/scripts/server.accept-timeout.json
+++ b/tests/scripts/server.accept-timeout.json
@@ -286,4 +286,4 @@
],
"return": 13
}
-]
\ No newline at end of file
+]
diff --git a/tests/scripts/server.accept.json b/tests/scripts/server.accept.json
index a1463dc..d617e01 100644
--- a/tests/scripts/server.accept.json
+++ b/tests/scripts/server.accept.json
@@ -13,6 +13,14 @@
],
"return": "127.0.0.1:12345"
},
+ {
+ "function": "stream_set_blocking",
+ "params": [
+ "@mock-stream",
+ "@int"
+ ],
+ "return": "@mock-stream"
+ },
{
"function": "stream_get_line",
"params": [
@@ -284,4 +292,4 @@
],
"return": "stream"
}
-]
\ No newline at end of file
+]