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 @@ [![Build Status](https://github.com/Textalk/websocket-php/actions/workflows/acceptance.yml/badge.svg)](https://github.com/Textalk/websocket-php/actions) [![Coverage Status](https://coveralls.io/repos/github/Textalk/websocket-php/badge.svg?branch=master)](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 +]