Skip to content

WebSocket push coroutines remain suspended after peer close with send_yield=true (6.2.2, SWOOLE_BASE)Β #6196

Description

@loks0n

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:

php server.php 1 9501

Run the client in another:

python3 client.py 9501

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions