You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

PHP+cURL连接WebSocket触发错误#23,无法维持心跳

问题

用PHP配合cURL连接WebSocket,连接成功且速度正常,能接收前两帧数据(第一帧1024B、第二帧774B),但之后触发错误#23“Failure writing output to destination”,连接中断。同时无法发送心跳包维持连接。

测试环境:

  • Windows10+XAMPP+PHP8.2+cURL8.3.0(本地管理员CLI模式)
  • Linux(root/sudo权限)
    调整CURLOPT_BUFFERSIZE参数后问题依旧。

代码示例

// URL and parameters
$url = 'https://www.example.com/?example_param=xxxx-xxxx';
$accessToken = 'xxxxxxxxxxx';
$webSocketKey = 'lSZolfGbvpepiTeZzjGomg==';

// Headers
$headers = [
    'Upgrade: websocket',
    'Connection: Upgrade',
    'Authorization: Bearer ' . $accessToken,
    'Sec-WebSocket-Key: ' . $webSocketKey,
    'Sec-WebSocket-Version: 13',
];

// cURL initialization
echo 'Init...START' . PHP_EOL;
$ch = curl_init();
echo 'Init...END' . PHP_EOL;

$outputJsonFile = __DIR__ . DIRECTORY_SEPARATOR . '_json.log';
$outputFile = __DIR__ . DIRECTORY_SEPARATOR . '_data.log';
$fileHandle = fopen($outputFile, 'w');
if ($fileHandle === false) {
    trigger_error('File err', E_USER_ERROR);
}

$objThis = new stdClass();
$objThis->result = '';
$objThis->strLen = 0;
$objThis->readCalls = 0;
$objThis->headers = '';

$callback = function ($ch, $str) use (&$objThis)
{
    // $objThis->result .= $str;
    $strTmp = trim(preg_replace('/[[:^print:]]/', '', $str), '~'); // should be aA
    // $strTmp = trim($str, "\x00\xff");

    $objThis->result .= $strTmp;

    $strLen = mb_strlen($str, '8bit'); // Same as strlen()

    $objThis->strLen += $strLen;

    echo PHP_EOL . '$result(cleaned)=' . (int)strlen($objThis->result) . PHP_EOL;
    echo PHP_EOL . '$str[' . mb_strlen($str) . '-' . mb_strlen($str, '8bit') . '-' . $strLen . ']=' . (int)strlen($str) . PHP_EOL;
    echo PHP_EOL . '$callback=' . $strTmp . PHP_EOL;

    return $objThis->strLen; //return the exact length
};

$callbackRead = function ($ch, $fh, $length) use (&$objThis)
{
    $objThis->readCalls++;
    return fread($fh, $length); // return 'PING';
};

$callbackHeader = function ($ch, $headerLine) use (&$objThis)
{
    $objThis->headers .= $headerLine;
    if (trim($headerLine) === 'Upgrade: websocket') {
        echo PHP_EOL . '#Connected!#' . PHP_EOL;
    }

    return strlen($headerLine);
};

$fR = tmpfile();
fwrite($fR, 'HeartBeat');
// Reset the file pointer
fseek($fR, 0);

// Set cURL options
$rO = curl_setopt_array($ch, [
    CURLOPT_URL => $url,
    CURLOPT_RETURNTRANSFER => true, // Return response as string
    CURLOPT_HTTPHEADER => $headers,
    CURLOPT_VERBOSE => true, // Verbose output
    CURLOPT_BUFFERSIZE => 10485760, //  receive buffer size 3 MB = 3145728 Bytes (in binary) 10 MB = 10485760 Bytes (in binary)
    // CURLOPT_WRITEDATA => $ch, // Not supported by PHP

    // CURLOPT_FILE => $fileHandle,
    CURLOPT_FOLLOWLOCATION => true,
    CURLOPT_FAILONERROR => true,
    CURLOPT_FORBID_REUSE => true,
    CURLOPT_FRESH_CONNECT => true,
    // CURLOPT_PUT => true, // Not appropriate for the case
    CURLOPT_HEADERFUNCTION => $callbackHeader, //
    CURLOPT_INFILE => $fR, // CallBack
    CURLOPT_INFILESIZE => mb_strlen('HeartBeat', '8bit'), // size
    CURLOPT_READFUNCTION => $callbackRead, // CallBack read
    CURLOPT_WRITEFUNCTION => $callback, // CallBack write
]);

echo PHP_EOL . 'Set cURL options' . PHP_EOL; var_dump($rO); PHP_EOL;

echo 'Exec...START' . PHP_EOL;
// Execute cURL request
$response = curl_exec($ch);
echo 'Exec...END' . PHP_EOL;

// Check for errors
if ($response === false) {
    echo 'cURL error [#' . curl_errno($ch) . ']: ' . curl_error($ch);
} else {
    // Output response
    echo 'Response: ' . $response;
}

// Close cURL handle
// curl_close($ch);
unset($ch);
fclose($fileHandle);

rewind($fR);
$tmlFile = stream_get_contents($fR);
fclose($fR);

$jTxt = json_decode($objThis->result);
$jP = json_encode($jTxt, JSON_PRETTY_PRINT);
file_put_contents($outputJsonFile, $jP);

echo PHP_EOL . 'readCalls: ' . $objThis->readCalls . PHP_EOL;
echo PHP_EOL . '$tmlFile: ' . $tmlFile . PHP_EOL;
echo PHP_EOL . 'result: ' . $jP . PHP_EOL;

die('.....................');

问题原因分析

  1. WRITEFUNCTION返回值错误:当前回调返回累计长度$objThis->strLen,但cURL要求该回调必须返回当前处理的字节数。返回值与实际处理字节数不匹配时,会触发错误#23。
  2. 心跳包格式不合法:直接发送字符串HeartBeat不符合WebSocket帧规范,服务器无法识别为心跳包;同时用CURLOPT_INFILE的方式不适合WebSocket的双向动态通信。
  3. 帧结构被破坏:代码中过滤非打印字符的操作直接破坏了WebSocket的帧头(包含opcode、长度等关键信息),导致后续帧解析失败。

解决方案

1. 修复WRITEFUNCTION返回值

将回调返回值改为当前处理的字节数,而非累计长度:

$callback = function ($ch, $str) use (&$objThis)
{
    // 先保留完整帧数据,后续再解析
    $objThis->result .= $str;
    $currentLen = strlen($str);

    echo PHP_EOL . '累计接收字节数=' . strlen($objThis->result) . PHP_EOL;
    echo PHP_EOL . '当前处理字节数=' . $currentLen . PHP_EOL;

    return $currentLen; // 必须返回当前处理的字节数
};

2. 实现合法的WebSocket心跳包

封装符合规范的PING帧,通过CURLOPT_READFUNCTION动态发送:

// 构建WebSocket PING帧(opcode=0x9)
function buildWebSocketPingFrame(string $data = ''): string
{
    $payloadLen = strlen($data);
    $frame = chr(0x9); // FIN=1,指定帧类型为PING

    // 处理负载长度
    if ($payloadLen < 126) {
        $frame .= chr($payloadLen);
    } elseif ($payloadLen < 65536) {
        $frame .= chr(126) . pack('n', $payloadLen);
    } else {
        $frame .= chr(127) . pack('J', $payloadLen);
    }

    return $frame . $data;
}

// 修改READFUNCTION,按需发送心跳
$callbackRead = function ($ch, $fh, $length) use (&$objThis)
{
    $objThis->readCalls++;
    // 这里可以根据连接时长判断是否发送心跳,示例直接返回PING帧
    return buildWebSocketPingFrame();
};

3. 调整cURL选项

移除不必要的选项,适配WebSocket协议:

$rO = curl_setopt_array($ch, [
    CURLOPT_URL => $url,
    CURLOPT_RETURNTRANSFER => false, // 使用WRITEFUNCTION时无需返回字符串
    CURLOPT_HTTPHEADER => $headers,
    CURLOPT_VERBOSE => true,
    CURLOPT_BUFFERSIZE => 10485760,
    CURLOPT_FAILONERROR => true,
    CURLOPT_FORBID_REUSE => true,
    CURLOPT_FRESH_CONNECT => true,
    CURLOPT_HEADERFUNCTION => $callbackHeader,
    CURLOPT_READFUNCTION => $callbackRead,
    CURLOPT_WRITEFUNCTION => $callback,
    // 移除CURLOPT_INFILE和CURLOPT_INFILESIZE,改用READFUNCTION动态发送数据
]);

4. 添加WebSocket帧解析逻辑

解析WebSocket帧,提取有效负载:

// 解析WebSocket帧
function parseWebSocketFrame(string $frame): array
{
    $offset = 0;
    $fin = (ord($frame[$offset]) >> 7) & 1;
    $opcode = ord($frame[$offset++]) & 0x0F;
    $masked = (ord($frame[$offset]) >> 7) & 1;
    $payloadLen = ord($frame[$offset++]) & 0x7F;

    // 处理大负载长度
    if ($payloadLen === 126) {
        $payloadLen = unpack('n', substr($frame, $offset, 2))[1];
        $offset += 2;
    } elseif ($payloadLen === 127) {
        $payloadLen = unpack('J', substr($frame, $offset, 8))[1];
        $offset += 8;
    }

    // 处理掩码
    $maskKey = $masked ? substr($frame, $offset, 4) : '';
    $offset += 4;
    $payload = substr($frame, $offset, $payloadLen);

    if ($masked) {
        for ($i = 0; $i < $payloadLen; $i++) {
            $payload[$i] = chr(ord($payload[$i]) ^ ord($maskKey[$i % 4]));
        }
    }

    return [
        'fin' => $fin,
        'opcode' => $opcode,
        'payload' => $payload,
    ];
}

// 在WRITEFUNCTION中解析帧
$callback = function ($ch, $str) use (&$objThis)
{
    $frameInfo = parseWebSocketFrame($str);
    // 区分帧类型处理
    match ($frameInfo['opcode']) {
        0x1 => { // 文本帧
            $objThis->result .= $frameInfo['payload'];
            echo PHP_EOL . '收到文本消息:' . $frameInfo['payload'] . PHP_EOL;
        },
        0xA => { // PONG帧(心跳响应)
            echo PHP_EOL . '收到心跳响应' . PHP_EOL;
        }
    };

    return strlen($str);
};

预期效果

通过以上修改,能够正确接收WebSocket帧,发送合法心跳包维持连接,实现双向消息收发。


内容的提问来源于stack exchange,提问作者PhpWebDev

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.22 21:52:33