workerman/http-client bug反馈?大数据包请求一直失败

来者可追

TcpConnection::baseWrite() 把 SSL 非阻塞写的背压误判为致命错误,导致约 128KB 以上的请求体必然断连

环境

  • workerman/workerman v4.1.15(另核对 v5.2.2 源码,问题同样存在)

  • workerman/http-client v2.2.9

  • PHP 8.3.31 / macOS (Darwin)

  • 事件循环:Workerman\Events\Select

  • maxSendBufferSize 为默认值 1048576,http-client 未覆盖

    现象

    用 workerman/http-client 发 POST,请求体 ≥约 128KB 时:

  • 连接在 0.14s 左右被关闭,onUnexpectClose 触发,报 The connection to [xxx] has been closed.

  • 请求体 < 64KB 时一切正常

    这不是上游的问题:同一份报文用 curl / Guzzle 发,HTTP 200
    正常返回;换成任意别的主机(实测过两家不同厂商)同样断连,所以是纯客户端行为。

    最小复现

    无需真实 API(失败发生在本地写路径,任意 HTTPS 地址都能复现):

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

    use Workerman\Worker;
    use Workerman\Http\Client;

    $worker = new Worker();
    $worker->onWorkerStart = function () {
    $body = json_encode(['x' => str_repeat('a', 160 * 1024)]); // 约 160KB

    (new Client(['connect_timeout' => 10, 'timeout' => 10]))->request(
    'https://open.bigmodel.cn/api/paas/v4/chat/completions', // 任意 HTTPS 地址均可
    [
    'method' => 'POST',
    'headers' => ['Content-Type' => 'application/json',
    'Authorization' => 'Bearer INVALID'],
    'data' => $body,
    'success' => function ($r) { echo "OK HTTP=" . $r->getStatusCode() . "\n"; Worker::stopAll(); },
    'error' => function ($e) { echo "FAIL " . $e->getMessage() . "\n"; Worker::stopAll(); },
    ]
    );
    };
    Worker::runAll();

    $ php repro.php start
    FAIL The connection to [xxx] has been closed. # 160KB →断连

    把 160 1024 改成 32 1024:

    $ php repro.php start
    OK HTTP=401 # 32KB →正常(401 是 bad key 导致,说明请求已送达)

    定位过程

    1. 确认走的是 baseWrite 的 else 分支

    TcpConnection::$statistics['send_fail'] 只有 4 处会自增:第 339、375、397、714 行。
    其中 339 和 397 要求 bufferIsFull()(1MB)、375 是非 SSL 专用分支。
    160KB 报文 + 默认 1MB 缓冲 →只有第 714 行可达。

    32KB →HTTP 401 send_fail: 0 →0
    160KB → 断连 send_fail: 0 → 1 ← 确认命中 baseWrite 的 else 分支

    1. 证明 SSL 非阻塞写返回 0 是正常背压、不是错误

    按 baseWrite() 的写法手工复现(同样是 164KB 报文、同样每次 8192 字节):

    第 1次: 写了 8192,剩 155881
    ...
    第16次: ⚠️ fwrite 返回 0 剩余=41193 openssl=(无 openssl 错误) eof=false timed_out=false
    第19次: ⚠️ fwrite 返回 0 剩余=24809 ...同上
    第20次: ⚠️ fwrite 返回 0 剩余=24809
    第22次: ⚠️ fwrite 返回 0 剩余=16617
    第25次: 写完剩余 233 字节 ✅ 全部发出

    关键三点:

  • 返回的是 0(不是 false)

  • openssl_error_string() 为空、eof === false、timed_out === false ——连接完全健康

  • 返回 0 之后它自己恢复了,最终 164073 字节全部发出

    即:socket 发送缓冲区满时 OpenSSL 返回 SSL_ERROR_WANT_WRITE,PHP 把它翻译成 fwrite 返回 0。这是非阻塞 I/O
    的标准语义,含义是「等会儿再写」,而不是「出错了」。

    1. 确认改成等待不会空转

    担心的是:若返回 0 后 socket 仍被 select 报告为可写,保留缓冲区会导致 EV_WRITE 反复触发、空转烧 CPU。

    实测返回 0 后立刻用 0 超时探测,连续 8 个样本全部是「不可写」:

    第16次返回0 剩 41157 select(0) 返回 0 → 不可写
    ...
    返回0次数 8,其中 select 探测总耗时 0.0000 秒

    所以保留缓冲区、等下一次可写事件是安全的:内核缓冲区排空前 EV_WRITE 不会触发。

    建议修复

    Connection/TcpConnection.php 中 baseWrite() 末尾:

       if ($len > 0) {
           $this->bytesWritten += $len;
           $this->_sendBuffer = \substr($this->_sendBuffer, $len);
  • } elseif ($this->transport === 'ssl' && \is_resource($this->_socket) && !\feof($this->_socket)) {

  • // SSL 非阻塞写在发送缓冲区满时返回 0(SSL_ERROR_WANT_WRITE),

  • // 这是背压信号而非错误:连接仍健康,缓冲区排空后会自行继续。

  • // 保留 _sendBuffer,等下一次 EV_WRITE;返回 0 后 socket 不可写,

  • // 不会空转。若对端始终不读,由上层超时收尾。
    } else {
    ++self::$statistics['send_fail'];
    $this->destroy();
    }

    即把非 SSL 分支本来就有的 is_resource / feof 判断补到 SSL 分支上——现在非 SSL 分支(第 374
    行附近)就是这么写的,SSL 分支漏了这一步。

    影响范围

    用 workerman/http-client 发送 ≥128KB 请求体的场景都会中招。之所以少见,大概是因为:

  • 大多数 http-client 用法(回调、webhook、API 调用)请求体只有几 KB,碰不到阈值

  • 需要请求体大到把本机 socket 发送缓冲区填满,同时依赖对端读取速度和 RTT

  • 「把十万字知识库整篇塞进 prompt」这类全文上下文用法是近一两年才多起来的,正好长期停在阈值之上

67 1 0
1个评论

walkor

感谢反馈,主干已经修复,等下个版本一起发布

  • 暂无评论

来者可追

420
积分
0
获赞数
0
粉丝数
2023-06-16 加入
🔝