@@ -49,20 +49,30 @@ type Client struct {
4949 stdin io.WriteCloser
5050 reader * messageReader
5151 writer * messageWriter
52- mu sync.Mutex
5352 closeMu sync.Mutex
53+ idMu sync.Mutex
5454 nextID int
5555 cleanup func ()
5656
57+ writeQueue chan writeOp
58+ writerOnce sync.Once
59+
5760 // dispatchMu guards the response-dispatch state shared with the single
58- // reader goroutine. It is never held across a blocking read.
61+ // reader goroutine. It is never held across a blocking read or write .
5962 dispatchMu sync.Mutex
6063 readerOnce sync.Once
6164 pending map [int ]chan dispatchResult
6265 readErr error
6366 readDone bool
6467}
6568
69+ type writeOp struct {
70+ message rpcMessage
71+ done chan error
72+ }
73+
74+ const writeQueueCapacity = 32
75+
6676// dispatchResult carries one matched JSON-RPC response (or a terminal reader
6777// error) to a waiting caller.
6878type dispatchResult struct {
@@ -307,20 +317,20 @@ func (client *Client) request(ctx context.Context, method string, params any, ta
307317 return err
308318 }
309319
310- // Allocate an id, register a response channel, and write the request while
311- // holding client.mu. The mutex serializes writes and id allocation but is
312- // released before the (potentially unbounded) wait for the response, so a
313- // hung server never holds the lock and blocks other callers/Close .
314- client .mu .Lock ()
320+ // Allocate an id and register a response channel. ID allocation and dispatch
321+ // registrations are fast non-blocking operations. Message transmission is
322+ // handled via writeMessage, ensuring a hung server never blocks other callers
323+ // or prevents a caller with a deadline from giving up .
324+ client .idMu .Lock ()
315325 id := client .nextID
316326 client .nextID ++
327+ client .idMu .Unlock ()
317328
318329 responses := make (chan dispatchResult , 1 )
319330 client .dispatchMu .Lock ()
320331 if client .readDone {
321332 readErr := client .readErr
322333 client .dispatchMu .Unlock ()
323- client .mu .Unlock ()
324334 if readErr != nil {
325335 return readErr
326336 }
@@ -329,16 +339,14 @@ func (client *Client) request(ctx context.Context, method string, params any, ta
329339 client .pending [id ] = responses
330340 client .dispatchMu .Unlock ()
331341
332- if err := client .writer . write ( rpcMessage {
342+ if err := client .writeMessage ( ctx , rpcMessage {
333343 ID : id ,
334344 Method : method ,
335345 Params : rawParams ,
336346 }); err != nil {
337347 client .removePending (id )
338- client .mu .Unlock ()
339348 return err
340349 }
341- client .mu .Unlock ()
342350
343351 select {
344352 case <- ctx .Done ():
@@ -361,6 +369,43 @@ func (client *Client) request(ctx context.Context, method string, params any, ta
361369 }
362370}
363371
372+ // ensureWriter lazily starts the single writer goroutine.
373+ func (client * Client ) ensureWriter () {
374+ client .writerOnce .Do (func () {
375+ if client .writeQueue == nil {
376+ client .writeQueue = make (chan writeOp , writeQueueCapacity )
377+ }
378+ go client .writeLoop ()
379+ })
380+ }
381+
382+ func (client * Client ) writeLoop () {
383+ for op := range client .writeQueue {
384+ err := client .writer .write (op .message )
385+ if op .done != nil {
386+ op .done <- err
387+ }
388+ }
389+ }
390+
391+ func (client * Client ) writeMessage (ctx context.Context , message rpcMessage ) error {
392+ client .ensureWriter ()
393+ done := make (chan error , 1 )
394+ op := writeOp {message : message , done : done }
395+ select {
396+ case <- ctx .Done ():
397+ return ctx .Err ()
398+ case client .writeQueue <- op :
399+ }
400+
401+ select {
402+ case <- ctx .Done ():
403+ return ctx .Err ()
404+ case err := <- done :
405+ return err
406+ }
407+ }
408+
364409// ensureReader lazily starts the single reader goroutine. It runs once per
365410// client; subsequent calls are no-ops.
366411func (client * Client ) ensureReader () {
@@ -389,21 +434,25 @@ func (client *Client) readLoop() {
389434 // It must never be routed as a response to a pending client request.
390435 if message .Method != "" {
391436 if message .ID != nil && jsonRPCIDEchoable (message .ID ) {
392- // Send the courtesy -32601 reply asynchronously off the read loop so
393- // an undrained server stdin pipe never stalls readLoop or holds client.mu.
437+ // Send the courtesy -32601 reply via the bounded writer queue. If the
438+ // queue is saturated (e.g. an undrained server pipe), drop the reply
439+ // immediately so it never stalls readLoop, holds a mutex, or blocks callers.
440+ client .ensureWriter ()
394441 id := message .ID
395442 method := message .Method
396- go func () {
397- client .mu .Lock ()
398- defer client .mu .Unlock ()
399- _ = client .writer .write (rpcMessage {
443+ courtesy := writeOp {
444+ message : rpcMessage {
400445 ID : id ,
401446 Error : & rpcError {
402447 Code : - 32601 ,
403448 Message : fmt .Sprintf ("Method %q not supported" , method ),
404449 },
405- })
406- }()
450+ },
451+ }
452+ select {
453+ case client .writeQueue <- courtesy :
454+ default :
455+ }
407456 }
408457 continue
409458 }
@@ -519,7 +568,7 @@ func (client *Client) notify(method string, params any) error {
519568 if err != nil {
520569 return err
521570 }
522- return client .writer . write ( rpcMessage {
571+ return client .writeMessage ( context . Background (), rpcMessage {
523572 Method : method ,
524573 Params : rawParams ,
525574 })
0 commit comments