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
19 changes: 18 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,24 @@

All notable changes to ext-websocket are documented here.

## 0.10.0 - 2026-05-18
## 1.1.0 - 2026-05-23

### Added

- Added `maxConnections`, `handshakeTimeoutMs`, and `idleTimeoutMs` server options to limit idle and slowloris-style connection pressure.
- Added UTF-8 validation for text messages and close reason payloads.
- Added validation for reserved WebSocket close codes on outgoing close frames.

### Changed

- Changed masked frame generation to use random mask keys instead of a deterministic mask.

### Fixed

- Fixed compatibility with PHP 8.1 and 8.2 when compiling generated class constants.
- Removed dependency on newer `ext/random` C headers when generating WebSocket mask keys.

## 1.0.0 - 2026-05-18

### Added

Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ Options:
|---|---|
| `maxMessageSize` | Maximum incoming text/binary message size; defaults to 16 MiB |
| `maxQueuedBytes` | Maximum outgoing queued bytes per connection; defaults to 16 MiB |
| `maxConnections` | Maximum concurrently accepted TCP connections; defaults to 10000 |
| `handshakeTimeoutMs` | Maximum idle time before HTTP Upgrade completes; defaults to 10000 ms |
| `idleTimeoutMs` | Maximum idle time after HTTP Upgrade completes; defaults to 120000 ms |

Prefer `WebSocket\ServerOptions` for explicit configuration. Associative arrays remain supported for compatibility.

Expand Down
6 changes: 5 additions & 1 deletion docs/production.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,13 @@ Set explicit limits for your workload:
$server = new WebSocket\Server(new WebSocket\ServerOptions(
maxMessageSize: 1024 * 1024,
maxQueuedBytes: 8 * 1024 * 1024,
maxConnections: 1000,
handshakeTimeoutMs: 5000,
idleTimeoutMs: 60000,
));
```

`maxMessageSize` protects incoming frames and fragmented messages. `maxQueuedBytes` protects memory when a client reads slowly and outgoing writes need to be queued.
`maxMessageSize` protects incoming frames and fragmented messages. `maxQueuedBytes` protects memory when a client reads slowly and outgoing writes need to be queued. `maxConnections`, `handshakeTimeoutMs`, and `idleTimeoutMs` protect file descriptors and event-loop work from slowloris-style or idle-connection pressure.

## Slow Clients

Expand Down Expand Up @@ -68,4 +71,5 @@ The extension intentionally keeps the public API small, so production metrics sh
- Disable Xdebug for benchmarks and production.
- Put `wss://` behind a TLS proxy.
- Start with conservative `maxMessageSize` and `maxQueuedBytes`.
- Set `maxConnections`, `handshakeTimeoutMs`, and `idleTimeoutMs` for the deployment envelope.
- Keep the extension and your PHP runtime on the same PHP minor version used during build.
11 changes: 10 additions & 1 deletion php_websocket.h
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,20 @@
#include "php.h"
#include "php_network.h"
#include "Zend/zend_closures.h"
#include "Zend/zend_enum.h"

#include <sys/socket.h>

extern zend_module_entry websocket_module_entry;
#define phpext_websocket_ptr &websocket_module_entry

#define PHP_WEBSOCKET_VERSION "0.10.0"
#define PHP_WEBSOCKET_VERSION "1.1.0"
#define WEBSOCKET_HTTP_MAX_REQUEST_SIZE 8192
#define WEBSOCKET_DEFAULT_MAX_MESSAGE_SIZE (16 * 1024 * 1024)
#define WEBSOCKET_DEFAULT_MAX_QUEUED_BYTES (16 * 1024 * 1024)
#define WEBSOCKET_DEFAULT_MAX_CONNECTIONS 10000
#define WEBSOCKET_DEFAULT_HANDSHAKE_TIMEOUT_MS 10000
#define WEBSOCKET_DEFAULT_IDLE_TIMEOUT_MS 120000
#define WEBSOCKET_CLOSE_REASON_MAX_LEN 123

#define WEBSOCKET_OPCODE_CONTINUATION 0x0
Expand All @@ -44,6 +48,7 @@ extern zend_module_entry websocket_module_entry;

#define WEBSOCKET_CLOSE_NORMAL 1000
#define WEBSOCKET_CLOSE_PROTOCOL_ERROR 1002
#define WEBSOCKET_CLOSE_INVALID_PAYLOAD 1007
#define WEBSOCKET_CLOSE_MESSAGE_TOO_BIG 1009

#ifdef ZTS
Expand Down Expand Up @@ -128,6 +133,8 @@ typedef struct _websocket_connection_object {
bool fragmented;
uint8_t fragmented_opcode;
zend_string *fragmented_payload;
uint64_t accepted_at_usec;
uint64_t last_activity_usec;
zend_object std;
} websocket_connection_object;

Expand Down Expand Up @@ -175,6 +182,8 @@ uint8_t websocket_protocol_message_type_opcode(zval *type);
zend_object *websocket_protocol_message_type_from_opcode(uint8_t opcode);
bool websocket_protocol_opcode_is_valid(zend_long opcode);
bool websocket_protocol_opcode_is_control(zend_long opcode);
bool websocket_protocol_close_code_is_valid(zend_long code);
bool websocket_protocol_is_valid_utf8(const char *payload, size_t payload_len);
zend_string *websocket_protocol_pack_payload(zend_string *payload, uint8_t opcode, uint8_t flags);
zend_string *websocket_protocol_close_payload(zend_long code, zend_string *reason);
websocket_http_upgrade_result websocket_http_parse_upgrade(const char *buffer, size_t len, zend_string **accept_key, size_t *bytes_consumed);
Expand Down
5 changes: 5 additions & 0 deletions php_websocket_compat.h
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ static inline zend_class_entry *zend_register_internal_class_with_flags(
}
#endif

#if PHP_VERSION_ID < 80300
# define zend_declare_typed_class_constant(ce, name, value, access_type, attributes, type) \
zend_declare_class_constant((ce), ZSTR_VAL(name), ZSTR_LEN(name), (value))
#endif

#if PHP_VERSION_ID >= 80300
# define WEBSOCKET_SET_DEFAULT_HANDLERS(ce, h) \
(ce)->default_object_handlers = (h)
Expand Down
10 changes: 8 additions & 2 deletions tests/001-contracts.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,12 @@ var_dump(method_exists(WebSocket\Server::class, 'send'));
var_dump(method_exists(WebSocket\Server::class, 'close'));
var_dump((new ReflectionMethod(WebSocket\Connection::class, 'send'))->getNumberOfParameters());
var_dump((new ReflectionMethod(WebSocket\ServerOptions::class, '__construct'))->getNumberOfParameters());
$options = new WebSocket\ServerOptions(maxMessageSize: 1024, maxQueuedBytes: 2048);
$options = new WebSocket\ServerOptions(maxMessageSize: 1024, maxQueuedBytes: 2048, maxConnections: 16, handshakeTimeoutMs: 250, idleTimeoutMs: 500);
var_dump($options->maxMessageSize);
var_dump($options->maxQueuedBytes);
var_dump($options->maxConnections);
var_dump($options->handshakeTimeoutMs);
var_dump($options->idleTimeoutMs);
try {
new WebSocket\ServerOptions(maxMessageSize: 0);
} catch (ValueError $e) {
Expand Down Expand Up @@ -45,9 +48,12 @@ bool(true)
bool(false)
bool(false)
int(2)
int(2)
int(5)
int(1024)
int(2048)
int(16)
int(250)
int(500)
WebSocket\ServerOptions::__construct(): Argument #1 ($maxMessageSize) must be at least 1
int(3)
int(2)
Expand Down
8 changes: 6 additions & 2 deletions tests/002-protocol.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@ var_dump($decoded->bytesConsumed);
var_dump(Protocol::decode(substr($frame, 0, 1)));

$masked = Protocol::encode('hi', MessageType::Text, true);
var_dump(bin2hex($masked));
var_dump(strlen($masked));
var_dump((ord($masked[1]) & 0x80) !== 0);
var_dump(Protocol::decode($masked)->payload);
var_dump($masked !== Protocol::encode('hi', MessageType::Text, true));
?>
--EXPECT--
string(28) "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="
Expand All @@ -36,5 +38,7 @@ string(5) "hello"
bool(true)
int(7)
NULL
string(16) "8182123456787a5d"
int(8)
bool(true)
string(2) "hi"
bool(true)
30 changes: 30 additions & 0 deletions tests/003-protocol-primitives.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,29 @@ try {
} catch (\Error $e) {
var_dump($e instanceof \Error);
}

try {
Protocol::encode("\xff");
} catch (\ValueError $e) {
var_dump($e instanceof \ValueError);
}

var_dump(bin2hex(Protocol::decode(Protocol::encode("\xff", MessageType::Binary))->payload));

try {
new CloseFrame(1000, "\xff");
} catch (\ValueError $e) {
var_dump($e instanceof \ValueError);
}

foreach ([1004, 1005, 1006, 1015] as $code) {
try {
new CloseFrame($code);
} catch (\ValueError $e) {
var_dump($e instanceof \ValueError);
}
}

?>
--EXPECT--
int(0)
Expand All @@ -76,3 +99,10 @@ int(1)
bool(true)
bool(true)
bool(true)
bool(true)
string(2) "ff"
bool(true)
bool(true)
bool(true)
bool(true)
bool(true)
135 changes: 135 additions & 0 deletions tests/013-server-connection-limits.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
--TEST--
WebSocket\Server enforces connection limits and handshake timeouts
--EXTENSIONS--
websocket
--SKIPIF--
<?php
if (!function_exists('proc_open')) {
die('skip proc_open is unavailable');
}
if (!function_exists('stream_socket_server') || !function_exists('stream_socket_client')) {
die('skip stream sockets are unavailable');
}
?>
--FILE--
<?php
$root = dirname(__DIR__);
$extension = $root . '/modules/websocket.so';
$tmpDir = sys_get_temp_dir() . '/websocket-server-limits-test-' . getmypid();
$serverFile = $tmpDir . '/server.php';

mkdir($tmpDir);

$probe = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr);
if (!$probe) {
echo "cannot allocate port\n";
exit;
}

$name = stream_socket_get_name($probe, false);
fclose($probe);
$port = (int) substr(strrchr($name, ':'), 1);

$serverCode = <<<'PHP'
<?php

use WebSocket\Server;
use WebSocket\ServerOptions;

$server = new Server(new ServerOptions(
maxMessageSize: 1024,
maxQueuedBytes: 1024,
maxConnections: 1,
handshakeTimeoutMs: 1000,
idleTimeoutMs: 10000,
));
$server->listen('127.0.0.1', PORT_PLACEHOLDER);
$server->run();
PHP;

$serverCode = str_replace('PORT_PLACEHOLDER', (string) $port, $serverCode);
file_put_contents($serverFile, $serverCode);

$process = proc_open(
[PHP_BINARY, '-n', '-d', 'extension=' . $extension, $serverFile],
[
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
],
$pipes,
);

if (!is_resource($process)) {
echo "cannot start server\n";
exit;
}

$first = false;
$deadline = microtime(true) + 5.0;
do {
$first = @stream_socket_client('tcp://127.0.0.1:' . $port, $errno, $errstr, 0.1);
if ($first !== false) {
break;
}

$status = proc_get_status($process);
if (!$status['running']) {
break;
}

usleep(10000);
} while (microtime(true) < $deadline);

if ($first !== false) {
fwrite($first, "GET /slow HTTP/1.1\r\n");
}

usleep(200000);

$second = @stream_socket_client('tcp://127.0.0.1:' . $port, $errno, $errstr, 0.5);
$secondResponse = '';
if ($second !== false) {
stream_set_timeout($second, 1);
$secondResponse = fread($second, 4096);
fclose($second);
}

if ($first !== false) {
stream_set_timeout($first, 2);
usleep(1200000);
$firstResponse = fread($first, 4096);
$firstMeta = stream_get_meta_data($first);
fclose($first);
} else {
$firstResponse = null;
$firstMeta = ['eof' => false];
}

$status = proc_get_status($process);
if ($status['running']) {
proc_terminate($process);
}

$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);

var_dump($first !== false);
var_dump($second !== false);
var_dump(str_contains($secondResponse, "HTTP/1.1 503 Service Unavailable\r\n"));
var_dump($firstResponse === '' && $firstMeta['eof']);
var_dump($stdout === '');
var_dump($stderr === '');

@unlink($serverFile);
@rmdir($tmpDir);
?>
--EXPECT--
bool(true)
bool(true)
bool(true)
bool(true)
bool(true)
bool(true)
Loading
Loading