Skip to content

Keep error handlers out of yielding calls under the Swoole event loop - #1170

Open
81549361 wants to merge 1 commit into
walkor:masterfrom
81549361:fix/swoole-error-handler-window
Open

81549361 wants to merge 1 commit into
walkor:masterfrom
81549361:fix/swoole-error-handler-window

Conversation

@81549361

Copy link
Copy Markdown

Problem

Under Workerman\Events\Swoole, warnings raised by other coroutines bypass the application's error handler while a connection is inside AsyncTcpConnection::connect() or TcpConnection::doSslHandshake(). With a framework handler that turns warnings into ErrorException (webman, Laravel, ...), code like $data['missing'] in an unrelated request silently returns null instead of throwing — but only while some other coroutine happens to be connecting or handshaking, so behaviour changes with concurrency.

In doSslHandshake() it is worse: the foreign warning is captured as $reason, so a failed handshake is reported as e.g. SSL handshake with 127.0.0.1:19443 failed: Undefined array key "missing".

We hit the first case in production: warnings from request code showed up as raw PHP warnings only on the Swoole pods, during windows where an AsyncTcpConnection was connecting to an unresponsive upstream (5 s+).

Root cause

Workerman\Events\Swoole enables SWOOLE_HOOK_ALL. With the hook:

  • stream_socket_client(..., STREAM_CLIENT_ASYNC_CONNECT) becomes a blocking connect (DNS lookup included) that yields to other coroutines;
  • stream_socket_enable_crypto() yields while waiting for the peer.

The set_error_handler() installed around both calls is process-wide, not per coroutine, so for the whole suspended period it receives every warning raised by every other coroutine.

Additionally, the hooked stream_socket_enable_crypto() does not raise PHP warnings on failure at all; it writes to Swoole's own log instead (PHP 8.2.16 / Swoole 6.2.2):

failing handshake native PHP stream Swoole hook
peer closes without TLS stream_socket_enable_crypto(): SSL: Handshake timed out (no warning), Swoole log: ssl_connect(...) failed ... unexpected eof while reading
handshake alert stream_socket_enable_crypto(): SSL operation failed with code 1. ... (no warning), Swoole log: ssl_connect(...) failed ... sslv3 alert handshake failure

So under Swoole the handler in doSslHandshake() can only ever catch warnings from other coroutines.

Fix

Only when the event loop is Workerman\Events\Swoole, use @ instead of set_error_handler() around those two calls. Swoole saves and restores the @ silence state per coroutine, so it does not leak into other coroutines. Every other event loop keeps the exact previous code path (same handler, same reason capture).

To avoid duplicating the five stream_socket_client() branches, connect() moves the socket creation into a private openSocket() that both paths call; its body is unchanged apart from early returns.

Reproduction

<?php
// LOOP=swoole php repro.php start  /  LOOP=select php repro.php start
use Workerman\Connection\AsyncTcpConnection;
use Workerman\Connection\TcpConnection;
use Workerman\Timer;
use Workerman\Worker;

require __DIR__ . '/vendor/autoload.php';

Worker::$eventLoopClass = getenv('LOOP') === 'swoole' ? Workerman\Events\Swoole::class : Workerman\Events\Select::class;

// A TCP peer that accepts and never speaks TLS, then hangs up: keeps the client inside the handshake.
$silent = new Worker('tcp://127.0.0.1:19443');
$silent->onConnect = static function (TcpConnection $c): void {
    Timer::add(1.5, static fn() => $c->destroy(), [], false);
};

$client = new Worker();
$client->onWorkerStart = static function (): void {
    // Typical framework handler, installed per worker like webman does.
    set_error_handler(static function (int $level, string $message, string $file = '', int $line = 0): void {
        if (error_reporting() & $level) {
            throw new ErrorException($message, 0, $level, $file, $line);
        }
    });
    $other = static function (string $window): void {
        $data = [];
        try {
            $data['missing'];
            echo "$window: other coroutine's warning BYPASSED the app handler\n";
        } catch (ErrorException) {
            echo "$window: other coroutine's warning converted by the app handler (ok)\n";
        }
    };

    // 1) connect() to a black-hole address
    Timer::add(0.01, static function (): void {
        $con = new AsyncTcpConnection('tcp://10.255.255.1:81');
        $con->onError = static fn($c, $code, $msg) => print("connect onError: $msg\n");
        $con->connect();
    }, [], false);
    Timer::add(0.3, static fn() => $other('connect window'), [], false);

    // 2) TLS handshake against the silent peer
    Timer::add(0.01, static function (): void {
        $con = new AsyncTcpConnection('ssl://127.0.0.1:19443', ['ssl' => ['verify_peer' => false, 'verify_peer_name' => false]]);
        $con->onError = static fn($c, $code, $msg) => print("handshake onError: $msg\n");
        $con->connect();
    }, [], false);
    Timer::add(0.6, static fn() => $other('handshake window'), [], false);

    Timer::add(4, static fn() => posix_kill(posix_getppid(), SIGINT), [], false);
};

Worker::runAll();

Results on PHP 8.2.16 / Swoole 6.2.2 / workerman master (8fd5c14):

before after
LOOP=select connect window: converted (ok)
handshake window: converted (ok)
SSL handshake with 127.0.0.1:19443 failed
identical
LOOP=swoole connect window: BYPASSED
handshake window: BYPASSED
SSL handshake with 127.0.0.1:19443 failed: Undefined array key "missing"
connect window: converted (ok)
handshake window: converted (ok)
SSL handshake with 127.0.0.1:19443 failed

Also checked on both loops after the change: a refused connect (tcp://127.0.0.1:1) is still reported through onError and nothing is thrown out of connect() by a throwing app handler; a real ssl://example.com:443 request still returns 200 OK. vendor/bin/pest: 247 passed, same as master; phpstan: no errors on both files.

Notes

  • The same pattern exists elsewhere in the ecosystem (e.g. symfony/process wraps stream_select() in set_error_handler()), but that is outside workerman.
  • If you prefer, the Swoole branch in connect() could stay inline instead of introducing openSocket(); I extracted it only to avoid duplicating the five stream_socket_client() calls.

Under Workerman\Events\Swoole (SWOOLE_HOOK_ALL) stream_socket_client() in
AsyncTcpConnection::connect() and stream_socket_enable_crypto() in
TcpConnection::doSslHandshake() yield to other coroutines: the hooked connect is
blocking (DNS lookup included) and the handshake waits for the peer. The
set_error_handler() installed around them is process-wide, so while the call is
suspended every warning raised by any other coroutine goes to it instead of the
application's handler:

- in connect() those warnings are silently dropped (fn() => false);
- in doSslHandshake() they are swallowed and the last one becomes the reported
  handshake failure reason ("SSL handshake with x failed: Undefined array key ...").

The hooked stream_socket_enable_crypto() reports failures to Swoole's own log, not
as PHP warnings, so under Swoole that handler can only ever catch other
coroutines' warnings. Use "@" there instead (Swoole saves the silence state per
coroutine). connect() moves the socket creation into openSocket() so both paths
share it. Every other event loop keeps the exact previous behaviour.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant