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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable changes to ext-websocket are documented here.

### Added

- Added opt-in server heartbeat pings with `pingIntervalMs` and `pongTimeoutMs` options.
- Added `WebSocket\Server::onHandshake()` with `WebSocket\Request`, `WebSocket\HandshakeResponse`, and `WebSocket\HandshakeException` for pre-upgrade handshake validation.

## 1.2.1 - 2026-05-23
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ Options:
| `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 |
| `pingIntervalMs` | Idle time before sending an automatic ping; `0` disables heartbeat pings by default |
| `pongTimeoutMs` | Maximum time to wait for a pong after an automatic ping; defaults to 10000 ms |

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

Expand Down
4 changes: 3 additions & 1 deletion docs/production.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,12 @@ $server = new WebSocket\Server(new WebSocket\ServerOptions(
maxConnections: 1000,
handshakeTimeoutMs: 5000,
idleTimeoutMs: 60000,
pingIntervalMs: 30000,
pongTimeoutMs: 10000,
));
```

`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.
`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. `pingIntervalMs` and `pongTimeoutMs` enable heartbeat pings and close peers that stop answering pongs.

## Slow Clients

Expand Down
4 changes: 4 additions & 0 deletions php_websocket.h
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ extern zend_module_entry websocket_module_entry;
#define WEBSOCKET_DEFAULT_MAX_CONNECTIONS 10000
#define WEBSOCKET_DEFAULT_HANDSHAKE_TIMEOUT_MS 10000
#define WEBSOCKET_DEFAULT_IDLE_TIMEOUT_MS 120000
#define WEBSOCKET_DEFAULT_PING_INTERVAL_MS 0
#define WEBSOCKET_DEFAULT_PONG_TIMEOUT_MS 10000
#define WEBSOCKET_CLOSE_REASON_MAX_LEN 123

#define WEBSOCKET_OPCODE_CONTINUATION 0x0
Expand Down Expand Up @@ -138,6 +140,8 @@ typedef struct _websocket_connection_object {
zend_string *fragmented_payload;
uint64_t accepted_at_usec;
uint64_t last_activity_usec;
uint64_t last_ping_usec;
bool awaiting_pong;
zend_object std;
} websocket_connection_object;

Expand Down
14 changes: 12 additions & 2 deletions tests/001-contracts.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,24 @@ var_dump((new ReflectionMethod(WebSocket\HandshakeException::class, '__construct
var_dump((new ReflectionMethod(WebSocket\Connection::class, 'send'))->getNumberOfParameters());
var_dump((new ReflectionProperty(WebSocket\Connection::class, 'subprotocol'))->getType()->allowsNull());
var_dump((new ReflectionMethod(WebSocket\ServerOptions::class, '__construct'))->getNumberOfParameters());
$options = new WebSocket\ServerOptions(maxMessageSize: 1024, maxQueuedBytes: 2048, maxConnections: 16, handshakeTimeoutMs: 250, idleTimeoutMs: 500);
$options = new WebSocket\ServerOptions(maxMessageSize: 1024, maxQueuedBytes: 2048, maxConnections: 16, handshakeTimeoutMs: 250, idleTimeoutMs: 500, pingIntervalMs: 0, pongTimeoutMs: 750);
var_dump($options->maxMessageSize);
var_dump($options->maxQueuedBytes);
var_dump($options->maxConnections);
var_dump($options->handshakeTimeoutMs);
var_dump($options->idleTimeoutMs);
var_dump($options->pingIntervalMs);
var_dump($options->pongTimeoutMs);
try {
new WebSocket\ServerOptions(maxMessageSize: 0);
} catch (ValueError $e) {
echo $e->getMessage(), "\n";
}
try {
new WebSocket\ServerOptions(pingIntervalMs: -1);
} catch (ValueError $e) {
echo $e->getMessage(), "\n";
}
var_dump((new ReflectionMethod(WebSocket\Frame::class, '__construct'))->getNumberOfParameters());
var_dump((new ReflectionMethod(WebSocket\CloseFrame::class, '__construct'))->getNumberOfParameters());

Expand Down Expand Up @@ -81,13 +88,16 @@ int(3)
int(1)
int(2)
bool(true)
int(5)
int(7)
int(1024)
int(2048)
int(16)
int(250)
int(500)
int(0)
int(750)
WebSocket\ServerOptions::__construct(): Argument #1 ($maxMessageSize) must be at least 1
WebSocket\ServerOptions::__construct(): Argument #6 ($pingIntervalMs) must be at least 0
int(3)
int(2)
WebSocket\Server::subprotocols(): Argument #1 must be a valid WebSocket subprotocol token
Expand Down
265 changes: 265 additions & 0 deletions tests/017-server-heartbeat.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
--TEST--
WebSocket\Server sends heartbeat pings and closes missing pongs
--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
use WebSocket\CloseFrame;
use WebSocket\Connection;
use WebSocket\Frame;
use WebSocket\MessageType;
use WebSocket\Protocol;
use WebSocket\Server;
use WebSocket\ServerOptions;

$root = dirname(__DIR__);
$extension = $root . '/modules/websocket.so';
$tmpDir = sys_get_temp_dir() . '/websocket-server-heartbeat-test-' . getmypid();
$eventsFile = $tmpDir . '/events.txt';
$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\Connection;
use WebSocket\Server;
use WebSocket\ServerOptions;

$server = new Server(new ServerOptions(
idleTimeoutMs: 3000,
pingIntervalMs: 100,
pongTimeoutMs: 250,
));
$server->listen('127.0.0.1', PORT_PLACEHOLDER);

$closed = 0;

$server->onOpen(static function (Connection $connection): void {
file_put_contents(EVENTS_PLACEHOLDER, "open\n", FILE_APPEND);
});

$server->onClose(static function (Connection $connection) use ($server, &$closed): void {
$closed++;
file_put_contents(EVENTS_PLACEHOLDER, "close\n", FILE_APPEND);

if ($closed >= 2) {
$server->stop();
}
});

$server->run();
file_put_contents(EVENTS_PLACEHOLDER, "returned\n", FILE_APPEND);
PHP;

$serverCode = str_replace(
['PORT_PLACEHOLDER', 'EVENTS_PLACEHOLDER'],
[(string) $port, var_export($eventsFile, true)],
$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;
}

$connect = static function () use ($port, $process): mixed {
$client = false;
$deadline = microtime(true) + 5.0;

do {
$client = @stream_socket_client('tcp://127.0.0.1:' . $port, $errno, $errstr, 0.1);
if ($client !== false) {
stream_set_timeout($client, 1);
return $client;
}

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

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

return false;
};

$readHeaders = static function ($client) use ($port): string {
fwrite($client, implode("\r\n", [
'GET / HTTP/1.1',
'Host: 127.0.0.1:' . $port,
'Upgrade: websocket',
'Connection: Upgrade',
'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==',
'Sec-WebSocket-Version: 13',
'',
'',
]));

$response = '';
$deadline = microtime(true) + 5.0;

do {
$chunk = fread($client, 4096);
if (is_string($chunk) && $chunk !== '') {
$response .= $chunk;
if (str_contains($response, "\r\n\r\n")) {
break;
}
}

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

return $response;
};

$readFrame = static function ($client, string &$buffer): Frame|CloseFrame|null {
$deadline = microtime(true) + 5.0;

do {
$frame = Protocol::unpack($buffer);
if ($frame !== null) {
$buffer = substr($buffer, $frame->bytesConsumed);
return $frame;
}

$chunk = fread($client, 4096);
if (is_string($chunk) && $chunk !== '') {
$buffer .= $chunk;
}

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

return null;
};

$client1 = $connect();
$response1 = '';
$buffer1 = '';
$ping1 = null;
$close1 = null;

if ($client1 !== false) {
$response1 = $readHeaders($client1);
$headerEnd = strpos($response1, "\r\n\r\n");
$buffer1 = $headerEnd === false ? '' : substr($response1, $headerEnd + 4);

$ping1 = $readFrame($client1, $buffer1);

if ($ping1 instanceof Frame) {
fwrite($client1, Protocol::pack($ping1->payload, Protocol::OPCODE_PONG, Protocol::FLAG_FIN | Protocol::FLAG_MASK));
}

fwrite($client1, Protocol::pack(pack('n', Protocol::CLOSE_NORMAL), Protocol::OPCODE_CLOSE, Protocol::FLAG_FIN | Protocol::FLAG_MASK));
$close1 = $readFrame($client1, $buffer1);
fclose($client1);
}

$client2 = $connect();
$response2 = '';
$buffer2 = '';
$ping2 = null;
$close2 = null;

if ($client2 !== false) {
$response2 = $readHeaders($client2);
$headerEnd = strpos($response2, "\r\n\r\n");
$buffer2 = $headerEnd === false ? '' : substr($response2, $headerEnd + 4);

$ping2 = $readFrame($client2, $buffer2);
$close2 = $readFrame($client2, $buffer2);

fclose($client2);
}

$deadline = microtime(true) + 5.0;
do {
$status = proc_get_status($process);
if (!$status['running']) {
break;
}

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

$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);

$events = file_exists($eventsFile) ? file($eventsFile, FILE_IGNORE_NEW_LINES) : [];

var_dump($client1 !== false);
var_dump(str_contains($response1, "HTTP/1.1 101 Switching Protocols\r\n"));
var_dump($ping1 instanceof Frame && $ping1->type === MessageType::Ping && $ping1->payload === '');
var_dump($close1 instanceof CloseFrame && $close1->code === Protocol::CLOSE_NORMAL);
var_dump($client2 !== false);
var_dump(str_contains($response2, "HTTP/1.1 101 Switching Protocols\r\n"));
var_dump($ping2 instanceof Frame && $ping2->type === MessageType::Ping && $ping2->payload === '');
var_dump($close2 instanceof CloseFrame && $close2->code === Protocol::CLOSE_NORMAL && $close2->reason === 'pong timeout');
var_dump(array_count_values($events));
var_dump($stdout === '');
var_dump($stderr === '');

@unlink($eventsFile);
@unlink($serverFile);
@rmdir($tmpDir);
?>
--EXPECT--
bool(true)
bool(true)
bool(true)
bool(true)
bool(true)
bool(true)
bool(true)
bool(true)
array(3) {
["open"]=>
int(2)
["close"]=>
int(2)
["returned"]=>
int(1)
}
bool(true)
bool(true)
Loading
Loading