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 →断连
$ php repro.php start
OK HTTP=401 # 32KB →正常(401 是 bad key 导致,说明请求已送达)
定位过程
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 分支
按 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
的标准语义,含义是「等会儿再写」,而不是「出错了」。
担心的是:若返回 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」这类全文上下文用法是近一两年才多起来的,正好长期停在阈值之上
感谢反馈,主干已经修复,等下个版本一起发布