@@ -244,6 +244,11 @@ export class EngineClient {
244244 if ( this . _ws && this . _ws . readyState === WebSocket . OPEN ) {
245245 return ;
246246 }
247+ // 清理残留连接
248+ if ( this . _ws ) {
249+ try { this . _ws . close ( ) ; } catch { /* ignore */ }
250+ this . _ws = null ;
251+ }
247252
248253 try {
249254 this . _ws = new WebSocket ( this . wsUrl ) ;
@@ -265,17 +270,33 @@ export class EngineClient {
265270
266271 this . _ws . on ( "close" , ( ) => {
267272 console . log ( "[TestPilot AI] WebSocket 断开,5秒后重连" ) ;
273+ this . _ws = null ;
268274 this . _scheduleReconnect ( ) ;
269275 } ) ;
270276
271277 this . _ws . on ( "error" , ( err ) => {
272278 console . error ( "[TestPilot AI] WebSocket 错误:" , err . message ) ;
279+ // error后通常会触发close,但保险起见也清理
273280 } ) ;
274281 } catch {
282+ this . _ws = null ;
275283 this . _scheduleReconnect ( ) ;
276284 }
277285 }
278286
287+ /** 确保 WebSocket 已连接(测试开始前调用) */
288+ ensureWsConnected ( ) : void {
289+ if ( this . _ws && this . _ws . readyState === WebSocket . OPEN ) {
290+ return ;
291+ }
292+ // 取消定时重连,立即重连
293+ if ( this . _reconnectTimer ) {
294+ clearTimeout ( this . _reconnectTimer ) ;
295+ this . _reconnectTimer = null ;
296+ }
297+ this . connectWs ( ) ;
298+ }
299+
279300 /** 断开 WebSocket */
280301 disconnectWs ( ) : void {
281302 if ( this . _reconnectTimer ) {
@@ -321,16 +342,54 @@ export class EngineClient {
321342
322343 private async _post < T > ( path : string , body : unknown ) : Promise < T > {
323344 const url = `${ this . httpUrl } ${ path } ` ;
324- const resp = await fetch ( url , {
325- method : "POST" ,
326- headers : { "Content-Type" : "application/json" } ,
327- body : JSON . stringify ( body ) ,
328- signal : AbortSignal . timeout ( 600_000 ) ,
345+ const payload = JSON . stringify ( body ) ;
346+
347+ // 使用 Node.js http 模块替代 fetch,避免 undici 的 bodyTimeout (300s)
348+ // 导致长时间测试(6分钟+)中途 "fetch failed"
349+ return new Promise < T > ( ( resolve , reject ) => {
350+ const parsed = new URL ( url ) ;
351+ const http = require ( "http" ) ;
352+ const req = http . request (
353+ {
354+ hostname : parsed . hostname ,
355+ port : parsed . port ,
356+ path : parsed . pathname + parsed . search ,
357+ method : "POST" ,
358+ headers : {
359+ "Content-Type" : "application/json" ,
360+ "Content-Length" : Buffer . byteLength ( payload ) ,
361+ } ,
362+ timeout : 900_000 , // 15分钟连接超时
363+ } ,
364+ ( res : any ) => {
365+ const chunks : Buffer [ ] = [ ] ;
366+ res . on ( "data" , ( chunk : Buffer ) => chunks . push ( chunk ) ) ;
367+ res . on ( "end" , ( ) => {
368+ const text = Buffer . concat ( chunks ) . toString ( "utf-8" ) ;
369+ if ( res . statusCode && res . statusCode >= 400 ) {
370+ reject ( new Error ( `HTTP ${ res . statusCode } : ${ text } ` ) ) ;
371+ } else {
372+ try {
373+ resolve ( JSON . parse ( text ) as T ) ;
374+ } catch {
375+ reject ( new Error ( `JSON解析失败: ${ text . substring ( 0 , 200 ) } ` ) ) ;
376+ }
377+ }
378+ } ) ;
379+ } ,
380+ ) ;
381+ req . on ( "timeout" , ( ) => {
382+ req . destroy ( ) ;
383+ reject ( new Error ( "请求超时(15分钟)" ) ) ;
384+ } ) ;
385+ req . on ( "error" , ( err : Error ) => reject ( err ) ) ;
386+ // 禁用 socket 空闲超时,防止长测试期间连接被断开
387+ req . on ( "socket" , ( socket : any ) => {
388+ socket . setTimeout ( 0 ) ;
389+ socket . setKeepAlive ( true , 30_000 ) ;
390+ } ) ;
391+ req . write ( payload ) ;
392+ req . end ( ) ;
329393 } ) ;
330- if ( ! resp . ok ) {
331- const text = await resp . text ( ) ;
332- throw new Error ( `HTTP ${ resp . status } : ${ text } ` ) ;
333- }
334- return resp . json ( ) as Promise < T > ;
335394 }
336395}
0 commit comments