1. What did you do?
In Swoole 6.2.2, a WebSocket client that stops reading can cause concurrent Server::push() calls to suspend when send_yield=true. After the client closes its TCP connection and the server's onClose callback runs, those pushes can remain suspended and retain their payloads.
This reproducer uses only Swoole and Python's standard library. It sends 300 distinct approximately 128 KiB messages to one non-reading client, with one worker in SWOOLE_BASE and an 8 MiB output buffer. started - finished counts pushes that have not returned. The closed flag is set only by the WebSocket connection's close callback, not by the HTTP stats connections.
Save as server.php:
<?php
// php server.php [send_yield: 1|0] [port]
$server = new Swoole\WebSocket\Server('127.0.0.1', (int) ($argv[2] ?? 9501), SWOOLE_BASE);
$server->ports[0]->set(['socket_buffer_size' => 8 * 1024 * 1024]);
$server->set([
'worker_num' => 1,
'send_yield' => (bool) (int) ($argv[1] ?? 1),
'send_timeout' => 0,
'log_level' => SWOOLE_LOG_ERROR,
]);
$started = $finished = 0;
$wsFd = null;
$closed = false;
$server->on('message', static function () {});
$server->on('open', function ($server, $request) use (&$started, &$finished, &$wsFd) {
$wsFd = $request->fd;
for ($i = 0; $i < 300; $i++) {
Swoole\Coroutine::create(function () use ($server, $wsFd, $i, &$started, &$finished) {
$started++;
$server->push($wsFd, str_repeat('x', 128 * 1024) . $i);
$finished++;
});
Swoole\Coroutine::sleep(0.003);
}
});
$server->on('close', function ($server, $fd) use (&$wsFd, &$closed) {
if ($fd === $wsFd) {
$closed = true;
}
});
$server->on('request', function ($request, $response) use (&$started, &$finished, &$closed) {
$response->end(json_encode([
'closed' => $closed,
'started' => $started,
'finished' => $finished,
'pending' => $started - $finished,
'coroutines' => Swoole\Coroutine::stats()['coroutine_num'],
'php_used' => memory_get_usage(),
'php_allocated' => memory_get_usage(true),
]));
});
$server->start();
Save as client.py:
# python3 client.py [port] -- requires only Python's standard library
import json
import socket
import sys
import time
import urllib.request
port = int(sys.argv[1]) if len(sys.argv) > 1 else 9501
def stats():
with urllib.request.urlopen(f'http://127.0.0.1:{port}/stats', timeout=3) as response:
return json.load(response)
print('baseline:', stats(), flush=True)
client = socket.socket()
client.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1024)
client.connect(('127.0.0.1', port))
client.sendall(b'GET / HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\n'
b'Connection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n'
b'Sec-WebSocket-Version: 13\r\n\r\n')
# Never read the WebSocket data, allowing the server's output buffer to fill.
time.sleep(2)
print('before close:', stats(), flush=True)
client.close()
time.sleep(1)
print('after close:', stats(), flush=True)
time.sleep(5)
print('five seconds later:', stats(), flush=True)
Run the server in one terminal:
Run the client in another:
The client never reads the WebSocket handshake response or application frames, then closes the TCP socket with unread inbound data. This tests abrupt TCP peer close, not the WebSocket closing handshake. The handshake completes on the server, confirmed by all 300 pushes starting.
Stop the server afterward. As a control, start a fresh server with yielding disabled and repeat the client:
php server.php 0 9501
python3 client.py 9501
The two commands above run in separate terminals, as in the first case. The control intentionally allows push failures; it checks whether calls finish, not whether all messages are delivered. Exact pending counts depend on socket buffering; increase the number of messages if a platform does not fill the buffer with 300.
2. What did you expect to see?
Once the peer is closed, pending push() calls should return failure and release their payload references. finished should reach started, pending should become zero, and the coroutine count should return to the stats-handler baseline of one. PHP used memory should return near baseline; allocated memory/RSS need not immediately shrink because of allocator retention.
3. What did you see instead?
Linux x86_64, PHP 8.5.9, Swoole 6.2.2:
send_yield=1
baseline: {'closed': False, 'started': 0, 'finished': 0, 'pending': 0, 'coroutines': 1, 'php_used': 2356464, 'php_allocated': 4194304}
before close: {'closed': False, 'started': 300, 'finished': 72, 'pending': 228, 'coroutines': 229, 'php_used': 97009616, 'php_allocated': 98566144}
after close: {'closed': True, 'started': 300, 'finished': 72, 'pending': 228, 'coroutines': 229, 'php_used': 96939984, 'php_allocated': 98566144}
five seconds later: {'closed': True, 'started': 300, 'finished': 72, 'pending': 228, 'coroutines': 229, 'php_used': 96939984, 'php_allocated': 98566144}
send_yield=0
baseline: {'closed': False, 'started': 0, 'finished': 0, 'pending': 0, 'coroutines': 1, 'php_used': 2356464, 'php_allocated': 4194304}
before close: {'closed': False, 'started': 300, 'finished': 300, 'pending': 0, 'coroutines': 1, 'php_used': 2426096, 'php_allocated': 4194304}
after close: {'closed': True, 'started': 300, 'finished': 300, 'pending': 0, 'coroutines': 1, 'php_used': 2356464, 'php_allocated': 4194304}
five seconds later: {'closed': True, 'started': 300, 'finished': 300, 'pending': 0, 'coroutines': 1, 'php_used': 2356464, 'php_allocated': 4194304}
On macOS arm64, the same scripts left 231 unfinished pushes, 232 coroutines including the stats request, and 98,100,960 bytes of PHP used memory after close. With yielding disabled, pending pushes were zero and PHP used memory returned to 2,272,920 bytes. A same-version earlier reproduction also reported sleeping/deadlocked Server::push() coroutines during shutdown.
The unfinished calls and elevated memory_get_usage(false) distinguish this from RSS/allocator high-water marks alone.
Suspected cause β source-based diagnosis, not an instrumented internal trace
php_swoole_server_onClose detaches the connection's waiting-send list, pops each waiter, sets ECONNRESET, and resumes it.
php_swoole_server_send_yield retries serv->send() after a successful yield_ex() return. Setting the last error to ECONNRESET does not itself make yield_ex() return false.
Server::send_to_connection checks conn->overflow in base mode before the later conn->peer_closed check. A resumed send can therefore get SW_ERROR_OUTPUT_SEND_YIELD again and re-enqueue while close is in progress.
- The close callback is processing the old detached list; the newly queued waiters have no subsequent buffer-drain/close event to wake them. With the default zero send timeout, they remain waiting.
The behavior above is reproduced independently of this diagnosis. I have not tested a source patch yet. A fix should make peer closure terminal for resumed sends, while handling waiter ownership safely across close, timeout, and cancellation.
4. Swoole version (php --ri swoole)
Both tested environments use 6.2.2. Full output from the macOS reproduction:
swoole
Swoole => enabled
Author => Swoole Team <team@swoole.com>
Version => 6.2.2
Built => Jul 15 2026 19:11:23
host byte order => little endian
coroutine => enabled with boost asm context
rwlock => enabled
sockets => enabled
openssl => OpenSSL 3.6.3 9 Jun 2026
dtls => enabled
http2 => enabled
json => enabled
curl-native => enabled
curl-version => 8.21.0
zlib => 1.2.12
brotli => E16785408/D16785408
mysqlnd => enabled
execinfo => enabled
Directive => Local Value => Master Value
swoole.enable_library => On => On
swoole.enable_fiber_mock => Off => Off
swoole.enable_preemptive_scheduler => Off => Off
swoole.display_errors => On => On
swoole.use_shortname => On => On
swoole.socket_buffer_size => 8388608 => 8388608
swoole.blocking_detection => Off => Off
swoole.blocking_threshold => 100000 => 100000
swoole.profile => Off => Off
swoole.leak_detection => Off => Off
5. Machine environment
Native macOS reproduction (host name omitted):
Darwin 25.6.0 arm64
PHP 8.5.9 (cli) (built: Jul 28 2026 13:06:52) (NTS)
Zend Engine v4.5.9
Zend OPcache v8.5.9
Apple clang version 21.0.0 (clang-2100.1.1.101)
Target: arm64-apple-darwin25.6.0
Also reproduced in an existing Linux amd64 PHP container under OrbStack on the arm64 host (the Linux run is emulated, not a native x86_64 machine):
Linux 7.0.14-orbstack-00380-ga7e0a2dc9535 x86_64 Linux
PHP 8.5.9 (cli) (built: Jul 30 2026 22:44:00) (NTS)
Zend Engine v4.5.9
Swoole 6.2.2, built Aug 7 2026 02:37:49
GCC is not installed in this runtime container.
No framework, Redis, or other application service is required by the reproducer. No claim is made here about other Swoole versions or SWOOLE_PROCESS mode.
1. What did you do?
In Swoole 6.2.2, a WebSocket client that stops reading can cause concurrent
Server::push()calls to suspend whensend_yield=true. After the client closes its TCP connection and the server'sonClosecallback runs, those pushes can remain suspended and retain their payloads.This reproducer uses only Swoole and Python's standard library. It sends 300 distinct approximately 128 KiB messages to one non-reading client, with one worker in
SWOOLE_BASEand an 8 MiB output buffer.started - finishedcounts pushes that have not returned. Theclosedflag is set only by the WebSocket connection's close callback, not by the HTTP stats connections.Save as
server.php:Save as
client.py:Run the server in one terminal:
Run the client in another:
The client never reads the WebSocket handshake response or application frames, then closes the TCP socket with unread inbound data. This tests abrupt TCP peer close, not the WebSocket closing handshake. The handshake completes on the server, confirmed by all 300 pushes starting.
Stop the server afterward. As a control, start a fresh server with yielding disabled and repeat the client:
The two commands above run in separate terminals, as in the first case. The control intentionally allows push failures; it checks whether calls finish, not whether all messages are delivered. Exact pending counts depend on socket buffering; increase the number of messages if a platform does not fill the buffer with 300.
2. What did you expect to see?
Once the peer is closed, pending
push()calls should return failure and release their payload references.finishedshould reachstarted,pendingshould become zero, and the coroutine count should return to the stats-handler baseline of one. PHP used memory should return near baseline; allocated memory/RSS need not immediately shrink because of allocator retention.3. What did you see instead?
Linux x86_64, PHP 8.5.9, Swoole 6.2.2:
On macOS arm64, the same scripts left 231 unfinished pushes, 232 coroutines including the stats request, and 98,100,960 bytes of PHP used memory after close. With yielding disabled, pending pushes were zero and PHP used memory returned to 2,272,920 bytes. A same-version earlier reproduction also reported sleeping/deadlocked
Server::push()coroutines during shutdown.The unfinished calls and elevated
memory_get_usage(false)distinguish this from RSS/allocator high-water marks alone.Suspected cause β source-based diagnosis, not an instrumented internal trace
php_swoole_server_onClosedetaches the connection's waiting-send list, pops each waiter, setsECONNRESET, and resumes it.php_swoole_server_send_yieldretriesserv->send()after a successfulyield_ex()return. Setting the last error toECONNRESETdoes not itself makeyield_ex()return false.Server::send_to_connectionchecksconn->overflowin base mode before the laterconn->peer_closedcheck. A resumed send can therefore getSW_ERROR_OUTPUT_SEND_YIELDagain and re-enqueue while close is in progress.The behavior above is reproduced independently of this diagnosis. I have not tested a source patch yet. A fix should make peer closure terminal for resumed sends, while handling waiter ownership safely across close, timeout, and cancellation.
4. Swoole version (
php --ri swoole)Both tested environments use 6.2.2. Full output from the macOS reproduction:
5. Machine environment
Native macOS reproduction (host name omitted):
Also reproduced in an existing Linux amd64 PHP container under OrbStack on the arm64 host (the Linux run is emulated, not a native x86_64 machine):
No framework, Redis, or other application service is required by the reproducer. No claim is made here about other Swoole versions or
SWOOLE_PROCESSmode.