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('.....................');
问题原因分析
- WRITEFUNCTION返回值错误:当前回调返回累计长度
$objThis->strLen,但cURL要求该回调必须返回当前处理的字节数。返回值与实际处理字节数不匹配时,会触发错误#23。 - 心跳包格式不合法:直接发送字符串
HeartBeat不符合WebSocket帧规范,服务器无法识别为心跳包;同时用CURLOPT_INFILE的方式不适合WebSocket的双向动态通信。 - 帧结构被破坏:代码中过滤非打印字符的操作直接破坏了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
相关产品推荐
相关产品推荐

