@@ -114,8 +114,7 @@ export class McpSseClient {
114114 private async openSse ( ) : Promise < void > {
115115 if ( this . abortController ) return ;
116116
117- // use shared abortController:header wait use timer abort;after getting header, clearTimeout,
118- // the long-lived stream is only ended by close()/session abort (compatible with Node 18, no AbortSignal.any).
117+ // One abortController for header/error-body wait; clear timer before the long-lived stream.
119118 this . abortController = new AbortController ( ) ;
120119 const timeoutMs = this . deps . settings . timeout * 1000 ;
121120 let headerTimedOut = false ;
@@ -146,6 +145,8 @@ export class McpSseClient {
146145 } ) ;
147146 } catch ( error ) {
148147 clearTimeout ( headerTimer ) ;
148+ // Allow a later initialize() to openSse again on this instance.
149+ this . abortController = undefined ;
149150 if ( this . closed ) {
150151 throw new BailianError ( "MCP SSE session closed." , ExitCode . GENERAL ) ;
151152 }
@@ -155,27 +156,43 @@ export class McpSseClient {
155156 throw new BailianError (
156157 `MCP SSE request failed: ${ error instanceof Error ? error . message : String ( error ) } ` ,
157158 ExitCode . NETWORK ,
159+ undefined ,
160+ { cause : error } ,
158161 ) ;
159162 }
160- // 已收到响应头:取消 header 等待,后续仅由 abortController 结束流。
161- clearTimeout ( headerTimer ) ;
162163
163164 if ( this . deps . settings . verbose ) {
164165 console . error ( `< ${ response . status } ${ response . statusText } ` ) ;
165166 }
166167
167168 if ( ! response . ok ) {
169+ // Keep headerTimer until error body is read (or times out).
168170 let errMsg = `MCP request failed: ${ response . status } ${ response . statusText } ` ;
169171 try {
170172 const errBody = await response . text ( ) ;
171173 if ( errBody ) errMsg += ` - ${ errBody . slice ( 0 , 500 ) } ` ;
172- } catch {
173- /* ignore */
174+ } catch ( error ) {
175+ clearTimeout ( headerTimer ) ;
176+ this . abortController = undefined ;
177+ if ( this . closed ) {
178+ throw new BailianError ( "MCP SSE session closed." , ExitCode . GENERAL ) ;
179+ }
180+ if ( headerTimedOut ) {
181+ throw new BailianError (
182+ "MCP SSE timed out reading error response body." ,
183+ ExitCode . TIMEOUT ,
184+ ) ;
185+ }
186+ throw new BailianError ( errMsg , ExitCode . GENERAL , undefined , { cause : error } ) ;
174187 }
175- // Throw only — do not rejectEndpoint; this path never awaits endpointReady.
188+ clearTimeout ( headerTimer ) ;
189+ this . abortController = undefined ;
190+ // Do not rejectEndpoint — openSse never awaits endpointReady on this path.
176191 throw new BailianError ( errMsg , ExitCode . GENERAL ) ;
177192 }
178193
194+ clearTimeout ( headerTimer ) ;
195+
179196 void this . consumeSse ( response ) . catch ( ( error ) => {
180197 if ( this . closed ) return ;
181198 const reason =
@@ -247,8 +264,7 @@ export class McpSseClient {
247264 throw error ;
248265 }
249266
250- // Stream ended after endpoint: mark session dead and wake pending; do not throw,
251- // so void consumeSse().catch does not surface an extra unhandled rejection.
267+ // After endpoint: mark dead and wake pending; don't throw (avoid unhandledRejection).
252268 this . markStreamEnded ( new BailianError ( "MCP SSE stream ended unexpectedly." , ExitCode . GENERAL ) ) ;
253269 }
254270
@@ -332,12 +348,24 @@ export class McpSseClient {
332348 }
333349
334350 const timeoutMs = this . deps . settings . timeout * 1000 ;
335- const res = await fetch ( this . messageUrl , {
336- method : "POST" ,
337- headers,
338- body : JSON . stringify ( body ) ,
339- signal : AbortSignal . timeout ( timeoutMs ) ,
340- } ) ;
351+ // Combine per-RPC timeout with session abort so close() cancels in-flight POSTs.
352+ const requestSignal = createLinkedAbortSignal ( timeoutMs , this . abortController ?. signal ) ;
353+ let res : Response ;
354+ try {
355+ res = await fetch ( this . messageUrl , {
356+ method : "POST" ,
357+ headers,
358+ body : JSON . stringify ( body ) ,
359+ signal : requestSignal . signal ,
360+ } ) ;
361+ } catch ( error ) {
362+ if ( this . closed ) {
363+ throw new BailianError ( "MCP SSE session closed." , ExitCode . GENERAL ) ;
364+ }
365+ throw error ;
366+ } finally {
367+ requestSignal . cleanup ( ) ;
368+ }
341369
342370 if ( this . deps . settings . verbose ) {
343371 console . error ( `< ${ res . status } ${ res . statusText } ` ) ;
@@ -406,3 +434,23 @@ function cancellableTimeoutReject(
406434 } ,
407435 } ;
408436}
437+
438+ /** Timeout + optional parent abort without AbortSignal.any (Node 18). */
439+ function createLinkedAbortSignal (
440+ timeoutMs : number ,
441+ parentSignal ?: AbortSignal ,
442+ ) : { signal : AbortSignal ; cleanup : ( ) => void } {
443+ const controller = new AbortController ( ) ;
444+ const timeout = setTimeout ( ( ) => controller . abort ( ) , timeoutMs ) ;
445+ const abortFromParent = ( ) => controller . abort ( parentSignal ?. reason ) ;
446+ const cleanup = ( ) => {
447+ clearTimeout ( timeout ) ;
448+ parentSignal ?. removeEventListener ( "abort" , abortFromParent ) ;
449+ } ;
450+
451+ if ( parentSignal ?. aborted ) abortFromParent ( ) ;
452+ else parentSignal ?. addEventListener ( "abort" , abortFromParent , { once : true } ) ;
453+ controller . signal . addEventListener ( "abort" , cleanup , { once : true } ) ;
454+
455+ return { signal : controller . signal , cleanup } ;
456+ }
0 commit comments