From 42b48eab1b962c00544620c66fa487527a840dc3 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:35:03 -0700 Subject: [PATCH 1/7] Avoid async IPC panic on peer close Track in-flight request handlers and propagate response write failures through Run instead of panicking from detached goroutines. Cancel handlers during connection shutdown and preserve concurrent errors. --- tsc/internal/ipc/conn_async.go | 38 +++++++++++++++----- tsc/internal/ipc/conn_async_test.go | 55 +++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 9 deletions(-) diff --git a/tsc/internal/ipc/conn_async.go b/tsc/internal/ipc/conn_async.go index 6094f8c1cd372..4d466afcf2d55 100644 --- a/tsc/internal/ipc/conn_async.go +++ b/tsc/internal/ipc/conn_async.go @@ -32,6 +32,10 @@ type AsyncConn struct { pendingMu sync.Mutex terminal error writeMu sync.Mutex + + handlers sync.WaitGroup + handlerErrMu sync.Mutex + handlerErr error } // NewAsyncConn creates a new async connection with the given transport and handler. @@ -64,7 +68,15 @@ func (c *AsyncConn) SetCollectTiming(enabled bool) { // Run starts processing messages on the connection. // It blocks until the context is cancelled or an error occurs. func (c *AsyncConn) Run(ctx context.Context) (err error) { - defer func() { c.closePendingCalls(err) }() + ctx, cancel := context.WithCancel(ctx) + defer func() { + cancel() + c.closePendingCalls(err) + c.handlers.Wait() + c.handlerErrMu.Lock() + defer c.handlerErrMu.Unlock() + err = errors.Join(err, c.handlerErr) + }() for { if ctx.Err() != nil { return ctx.Err() @@ -81,7 +93,14 @@ func (c *AsyncConn) Run(ctx context.Context) (err error) { if msg.IsResponse() { c.handleResponse(msg) } else if msg.IsRequest() { - go c.handleRequest(ctx, msg) + c.handlers.Go(func() { + if handlerErr := c.handleRequest(ctx, msg); handlerErr != nil { + c.handlerErrMu.Lock() + c.handlerErr = errors.Join(c.handlerErr, handlerErr) + c.handlerErrMu.Unlock() + _ = c.rwc.Close() + } + }) } else if msg.IsNotification() { go c.handleNotification(ctx, msg) } @@ -120,7 +139,7 @@ func (c *AsyncConn) handleResponse(msg *Message) { } // handleRequest processes an incoming request. -func (c *AsyncConn) handleRequest(ctx context.Context, msg *Message) { +func (c *AsyncConn) handleRequest(ctx context.Context, msg *Message) (retErr error) { // Intercept the meta-requests for collected server timing before dispatching // to the handler, so they are answered directly and not themselves recorded. switch msg.Method { @@ -129,9 +148,9 @@ func (c *AsyncConn) handleRequest(ctx context.Context, msg *Message) { writeErr := c.protocol.WriteResponse(msg.ID, serverTimingSnapshot(c.timing)) c.writeMu.Unlock() if writeErr != nil { - panic(fmt.Sprintf("ipc: failed to write server timing response: %v", writeErr)) + return fmt.Errorf("ipc: failed to write server timing response: %w", writeErr) } - return + return nil case string(MethodResetServerTiming): if c.timing != nil { c.timing.reset() @@ -140,9 +159,9 @@ func (c *AsyncConn) handleRequest(ctx context.Context, msg *Message) { writeErr := c.protocol.WriteResponse(msg.ID, nil) c.writeMu.Unlock() if writeErr != nil { - panic(fmt.Sprintf("ipc: failed to write reset server timing response: %v", writeErr)) + return fmt.Errorf("ipc: failed to write reset server timing response: %w", writeErr) } - return + return nil } var result any @@ -167,7 +186,7 @@ func (c *AsyncConn) handleRequest(ctx context.Context, msg *Message) { c.writeMu.Unlock() if writeErr != nil { - panic(fmt.Sprintf("ipc: failed to write panic error response: %v (original panic: %v)", writeErr, r)) + retErr = fmt.Errorf("ipc: failed to write panic error response: %w (original panic: %v)", writeErr, r) } } }() @@ -192,8 +211,9 @@ func (c *AsyncConn) handleRequest(ctx context.Context, msg *Message) { } if writeErr != nil { - panic(fmt.Sprintf("ipc: failed to write response: %v", writeErr)) + return fmt.Errorf("ipc: failed to write response: %w", writeErr) } + return nil } // handleNotification processes an incoming notification. diff --git a/tsc/internal/ipc/conn_async_test.go b/tsc/internal/ipc/conn_async_test.go index f5269216a2383..2a8f4f79f5d71 100644 --- a/tsc/internal/ipc/conn_async_test.go +++ b/tsc/internal/ipc/conn_async_test.go @@ -10,6 +10,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/ipc" "github.com/microsoft/TypeScript/tsc/internal/json" + "github.com/microsoft/TypeScript/tsc/internal/jsonrpc" "gotest.tools/v3/assert" ) @@ -23,6 +24,21 @@ func (noOpHandler) HandleNotification(context.Context, string, json.Value) error return nil } +type blockingHandler struct { + started chan struct{} + release chan struct{} +} + +func (h blockingHandler) HandleRequest(context.Context, string, json.Value) (any, error) { + close(h.started) + <-h.release + return nil, nil +} + +func (blockingHandler) HandleNotification(context.Context, string, json.Value) error { + return nil +} + func TestAsyncConnCallReturnsWhenPeerCloses(t *testing.T) { t.Parallel() client, server := net.Pipe() @@ -67,3 +83,42 @@ func TestAsyncConnCallAfterReadLoopFailureReturnsImmediately(t *testing.T) { err = conn.Notify(ctx, "changed", nil) assert.Assert(t, errors.Is(err, ipc.ErrConnClosed), "expected ErrConnClosed, got %v", err) } + +func TestAsyncConnRunReturnsWhenPeerClosesDuringRequest(t *testing.T) { + t.Parallel() + client, server := net.Pipe() + defer server.Close() + handler := blockingHandler{ + started: make(chan struct{}), + release: make(chan struct{}), + } + defer func() { + select { + case <-handler.release: + return + default: + close(handler.release) + } + }() + conn := ipc.NewAsyncConn(server, handler) + runDone := make(chan error, 1) + go func() { runDone <- conn.Run(t.Context()) }() + + protocol := ipc.NewJSONRPCProtocol(client) + assert.NilError(t, protocol.WriteRequest(jsonrpc.NewIDInt(1), "transform", nil)) + select { + case <-handler.started: + break + case <-time.After(time.Second): + t.Fatal("request handler did not start") + } + assert.NilError(t, client.Close()) + close(handler.release) + + select { + case err := <-runDone: + assert.ErrorContains(t, err, "ipc: failed to write response") + case <-time.After(time.Second): + t.Fatal("connection did not stop") + } +} From bb884bf2e28be93e1dc20d6cc52db8b158c3dcaf Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:52:49 -0700 Subject: [PATCH 2/7] Avoid waiting for async IPC handlers Report completed request errors without synchronously draining request handlers during connection teardown. Cover peer closure while a handler remains blocked. --- tsc/internal/ipc/conn_async.go | 33 +++++++++++++++++------------ tsc/internal/ipc/conn_async_test.go | 18 ++++++++++++---- 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/tsc/internal/ipc/conn_async.go b/tsc/internal/ipc/conn_async.go index 4d466afcf2d55..d107c332b67ad 100644 --- a/tsc/internal/ipc/conn_async.go +++ b/tsc/internal/ipc/conn_async.go @@ -32,10 +32,6 @@ type AsyncConn struct { pendingMu sync.Mutex terminal error writeMu sync.Mutex - - handlers sync.WaitGroup - handlerErrMu sync.Mutex - handlerErr error } // NewAsyncConn creates a new async connection with the given transport and handler. @@ -69,13 +65,24 @@ func (c *AsyncConn) SetCollectTiming(enabled bool) { // It blocks until the context is cancelled or an error occurs. func (c *AsyncConn) Run(ctx context.Context) (err error) { ctx, cancel := context.WithCancel(ctx) + requestErrors := make(chan error, 1) + reportRequestError := func(requestErr error) { + select { + case requestErrors <- requestErr: + return + default: + return + } + } defer func() { cancel() c.closePendingCalls(err) - c.handlers.Wait() - c.handlerErrMu.Lock() - defer c.handlerErrMu.Unlock() - err = errors.Join(err, c.handlerErr) + select { + case requestErr := <-requestErrors: + err = errors.Join(err, requestErr) + default: + return + } }() for { if ctx.Err() != nil { @@ -93,14 +100,12 @@ func (c *AsyncConn) Run(ctx context.Context) (err error) { if msg.IsResponse() { c.handleResponse(msg) } else if msg.IsRequest() { - c.handlers.Go(func() { - if handlerErr := c.handleRequest(ctx, msg); handlerErr != nil { - c.handlerErrMu.Lock() - c.handlerErr = errors.Join(c.handlerErr, handlerErr) - c.handlerErrMu.Unlock() + go func() { + if requestErr := c.handleRequest(ctx, msg); requestErr != nil { + reportRequestError(requestErr) _ = c.rwc.Close() } - }) + }() } else if msg.IsNotification() { go c.handleNotification(ctx, msg) } diff --git a/tsc/internal/ipc/conn_async_test.go b/tsc/internal/ipc/conn_async_test.go index 2a8f4f79f5d71..50018e19570c5 100644 --- a/tsc/internal/ipc/conn_async_test.go +++ b/tsc/internal/ipc/conn_async_test.go @@ -27,11 +27,13 @@ func (noOpHandler) HandleNotification(context.Context, string, json.Value) error type blockingHandler struct { started chan struct{} release chan struct{} + done chan struct{} } func (h blockingHandler) HandleRequest(context.Context, string, json.Value) (any, error) { close(h.started) <-h.release + close(h.done) return nil, nil } @@ -91,6 +93,7 @@ func TestAsyncConnRunReturnsWhenPeerClosesDuringRequest(t *testing.T) { handler := blockingHandler{ started: make(chan struct{}), release: make(chan struct{}), + done: make(chan struct{}), } defer func() { select { @@ -113,12 +116,19 @@ func TestAsyncConnRunReturnsWhenPeerClosesDuringRequest(t *testing.T) { t.Fatal("request handler did not start") } assert.NilError(t, client.Close()) - close(handler.release) select { - case err := <-runDone: - assert.ErrorContains(t, err, "ipc: failed to write response") + case <-runDone: + break + case <-time.After(time.Second): + t.Fatal("connection did not stop while request handler was blocked") + } + + close(handler.release) + select { + case <-handler.done: + break case <-time.After(time.Second): - t.Fatal("connection did not stop") + t.Fatal("request handler did not stop") } } From 9353dc6367ea03b9e0ab51e57967626762eaf39c Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:24:10 -0700 Subject: [PATCH 3/7] Preserve async IPC request errors Join a completed request-handler failure before recording the connection's terminal error so later calls observe the cause that closed the transport. --- tsc/internal/ipc/conn_async.go | 3 +- tsc/internal/ipc/conn_async_test.go | 66 +++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/tsc/internal/ipc/conn_async.go b/tsc/internal/ipc/conn_async.go index d107c332b67ad..4d54783483ccb 100644 --- a/tsc/internal/ipc/conn_async.go +++ b/tsc/internal/ipc/conn_async.go @@ -76,13 +76,12 @@ func (c *AsyncConn) Run(ctx context.Context) (err error) { } defer func() { cancel() - c.closePendingCalls(err) select { case requestErr := <-requestErrors: err = errors.Join(err, requestErr) default: - return } + c.closePendingCalls(err) }() for { if ctx.Err() != nil { diff --git a/tsc/internal/ipc/conn_async_test.go b/tsc/internal/ipc/conn_async_test.go index 50018e19570c5..9f2d1fd5905a6 100644 --- a/tsc/internal/ipc/conn_async_test.go +++ b/tsc/internal/ipc/conn_async_test.go @@ -5,6 +5,7 @@ import ( "errors" "io" "net" + "sync" "testing" "time" @@ -41,6 +42,55 @@ func (blockingHandler) HandleNotification(context.Context, string, json.Value) e return nil } +type closeSignal struct { + closed chan struct{} + once sync.Once +} + +func (*closeSignal) Read([]byte) (int, error) { + return 0, io.EOF +} + +func (*closeSignal) Write(p []byte) (int, error) { + return len(p), nil +} + +func (c *closeSignal) Close() error { + c.once.Do(func() { close(c.closed) }) + return nil +} + +type failingResponseProtocol struct { + closed <-chan struct{} + requestRead bool + responseErr error +} + +func (p *failingResponseProtocol) ReadMessage() (*ipc.Message, error) { + if !p.requestRead { + p.requestRead = true + return &ipc.Message{ID: jsonrpc.NewIDInt(1), Method: "transform"}, nil + } + <-p.closed + return nil, io.ErrClosedPipe +} + +func (*failingResponseProtocol) WriteRequest(*jsonrpc.ID, string, any) error { + return nil +} + +func (*failingResponseProtocol) WriteNotification(string, any) error { + return nil +} + +func (p *failingResponseProtocol) WriteResponse(*jsonrpc.ID, any) error { + return p.responseErr +} + +func (p *failingResponseProtocol) WriteError(*jsonrpc.ID, *jsonrpc.ResponseError) error { + return p.responseErr +} + func TestAsyncConnCallReturnsWhenPeerCloses(t *testing.T) { t.Parallel() client, server := net.Pipe() @@ -86,6 +136,22 @@ func TestAsyncConnCallAfterReadLoopFailureReturnsImmediately(t *testing.T) { assert.Assert(t, errors.Is(err, ipc.ErrConnClosed), "expected ErrConnClosed, got %v", err) } +func TestAsyncConnTerminalErrorIncludesResponseWriteFailure(t *testing.T) { + t.Parallel() + responseErr := errors.New("response write failed") + rwc := &closeSignal{closed: make(chan struct{})} + protocol := &failingResponseProtocol{ + closed: rwc.closed, + responseErr: responseErr, + } + conn := ipc.NewAsyncConnWithProtocol(rwc, protocol, noOpHandler{}) + + err := conn.Run(t.Context()) + assert.Assert(t, errors.Is(err, responseErr), "expected response write error, got %v", err) + _, err = conn.Call(t.Context(), "transform", nil) + assert.Assert(t, errors.Is(err, responseErr), "expected terminal response write error, got %v", err) +} + func TestAsyncConnRunReturnsWhenPeerClosesDuringRequest(t *testing.T) { t.Parallel() client, server := net.Pipe() From 69798ca6bd460d478483709afce063dfba034355 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:26:27 -0700 Subject: [PATCH 4/7] Record late async IPC request errors Update connection terminal state when a request response write fails, including after Run has returned, so later calls retain the transport failure. --- tsc/internal/ipc/conn_async.go | 7 +++--- tsc/internal/ipc/conn_async_test.go | 35 +++++++++++++++++++++-------- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/tsc/internal/ipc/conn_async.go b/tsc/internal/ipc/conn_async.go index 4d54783483ccb..aad9d565a137f 100644 --- a/tsc/internal/ipc/conn_async.go +++ b/tsc/internal/ipc/conn_async.go @@ -101,6 +101,7 @@ func (c *AsyncConn) Run(ctx context.Context) (err error) { } else if msg.IsRequest() { go func() { if requestErr := c.handleRequest(ctx, msg); requestErr != nil { + c.closePendingCalls(requestErr) reportRequestError(requestErr) _ = c.rwc.Close() } @@ -117,9 +118,9 @@ func (c *AsyncConn) closePendingCalls(runErr error) { defer c.pendingMu.Unlock() if c.terminal == nil { c.terminal = ErrConnClosed - if runErr != nil { - c.terminal = errors.Join(c.terminal, runErr) - } + } + if runErr != nil { + c.terminal = errors.Join(c.terminal, runErr) } for id, ch := range c.pending { close(ch) diff --git a/tsc/internal/ipc/conn_async_test.go b/tsc/internal/ipc/conn_async_test.go index 9f2d1fd5905a6..943dbd1d2100b 100644 --- a/tsc/internal/ipc/conn_async_test.go +++ b/tsc/internal/ipc/conn_async_test.go @@ -28,13 +28,11 @@ func (noOpHandler) HandleNotification(context.Context, string, json.Value) error type blockingHandler struct { started chan struct{} release chan struct{} - done chan struct{} } func (h blockingHandler) HandleRequest(context.Context, string, json.Value) (any, error) { close(h.started) <-h.release - close(h.done) return nil, nil } @@ -91,6 +89,17 @@ func (p *failingResponseProtocol) WriteError(*jsonrpc.ID, *jsonrpc.ResponseError return p.responseErr } +type responseNotifyingProtocol struct { + ipc.Protocol + responseDone chan error +} + +func (p *responseNotifyingProtocol) WriteResponse(id *jsonrpc.ID, result any) error { + err := p.Protocol.WriteResponse(id, result) + p.responseDone <- err + return err +} + func TestAsyncConnCallReturnsWhenPeerCloses(t *testing.T) { t.Parallel() client, server := net.Pipe() @@ -159,7 +168,6 @@ func TestAsyncConnRunReturnsWhenPeerClosesDuringRequest(t *testing.T) { handler := blockingHandler{ started: make(chan struct{}), release: make(chan struct{}), - done: make(chan struct{}), } defer func() { select { @@ -169,12 +177,17 @@ func TestAsyncConnRunReturnsWhenPeerClosesDuringRequest(t *testing.T) { close(handler.release) } }() - conn := ipc.NewAsyncConn(server, handler) + responseDone := make(chan error, 1) + protocol := &responseNotifyingProtocol{ + Protocol: ipc.NewJSONRPCProtocol(server), + responseDone: responseDone, + } + conn := ipc.NewAsyncConnWithProtocol(server, protocol, handler) runDone := make(chan error, 1) go func() { runDone <- conn.Run(t.Context()) }() - protocol := ipc.NewJSONRPCProtocol(client) - assert.NilError(t, protocol.WriteRequest(jsonrpc.NewIDInt(1), "transform", nil)) + clientProtocol := ipc.NewJSONRPCProtocol(client) + assert.NilError(t, clientProtocol.WriteRequest(jsonrpc.NewIDInt(1), "transform", nil)) select { case <-handler.started: break @@ -192,9 +205,13 @@ func TestAsyncConnRunReturnsWhenPeerClosesDuringRequest(t *testing.T) { close(handler.release) select { - case <-handler.done: - break + case responseErr := <-responseDone: + assert.Assert(t, responseErr != nil, "expected response write to fail") + _, err := conn.Call(t.Context(), "transform", nil) + assert.Assert(t, errors.Is(err, responseErr), "expected terminal response write error, got %v", err) + err = conn.Notify(t.Context(), "changed", nil) + assert.Assert(t, errors.Is(err, responseErr), "expected terminal response write error, got %v", err) case <-time.After(time.Second): - t.Fatal("request handler did not stop") + t.Fatal("response write did not finish") } } From 1a53158e1971a121f0408cc4d6a91b865bb83fd8 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:29:02 -0700 Subject: [PATCH 5/7] Publish async IPC write failures immediately Record response-write failures in terminal connection state before returning from request handling, eliminating the observer race found during review. --- tsc/internal/ipc/conn_async.go | 14 +++++++++---- tsc/internal/ipc/conn_async_test.go | 32 ++++++++++++++--------------- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/tsc/internal/ipc/conn_async.go b/tsc/internal/ipc/conn_async.go index aad9d565a137f..61c28003e38db 100644 --- a/tsc/internal/ipc/conn_async.go +++ b/tsc/internal/ipc/conn_async.go @@ -101,7 +101,6 @@ func (c *AsyncConn) Run(ctx context.Context) (err error) { } else if msg.IsRequest() { go func() { if requestErr := c.handleRequest(ctx, msg); requestErr != nil { - c.closePendingCalls(requestErr) reportRequestError(requestErr) _ = c.rwc.Close() } @@ -153,7 +152,9 @@ func (c *AsyncConn) handleRequest(ctx context.Context, msg *Message) (retErr err writeErr := c.protocol.WriteResponse(msg.ID, serverTimingSnapshot(c.timing)) c.writeMu.Unlock() if writeErr != nil { - return fmt.Errorf("ipc: failed to write server timing response: %w", writeErr) + requestErr := fmt.Errorf("ipc: failed to write server timing response: %w", writeErr) + c.closePendingCalls(requestErr) + return requestErr } return nil case string(MethodResetServerTiming): @@ -164,7 +165,9 @@ func (c *AsyncConn) handleRequest(ctx context.Context, msg *Message) (retErr err writeErr := c.protocol.WriteResponse(msg.ID, nil) c.writeMu.Unlock() if writeErr != nil { - return fmt.Errorf("ipc: failed to write reset server timing response: %w", writeErr) + requestErr := fmt.Errorf("ipc: failed to write reset server timing response: %w", writeErr) + c.closePendingCalls(requestErr) + return requestErr } return nil } @@ -192,6 +195,7 @@ func (c *AsyncConn) handleRequest(ctx context.Context, msg *Message) (retErr err if writeErr != nil { retErr = fmt.Errorf("ipc: failed to write panic error response: %w (original panic: %v)", writeErr, r) + c.closePendingCalls(retErr) } } }() @@ -216,7 +220,9 @@ func (c *AsyncConn) handleRequest(ctx context.Context, msg *Message) (retErr err } if writeErr != nil { - return fmt.Errorf("ipc: failed to write response: %w", writeErr) + requestErr := fmt.Errorf("ipc: failed to write response: %w", writeErr) + c.closePendingCalls(requestErr) + return requestErr } return nil } diff --git a/tsc/internal/ipc/conn_async_test.go b/tsc/internal/ipc/conn_async_test.go index 943dbd1d2100b..89a736e5fb1d8 100644 --- a/tsc/internal/ipc/conn_async_test.go +++ b/tsc/internal/ipc/conn_async_test.go @@ -89,15 +89,15 @@ func (p *failingResponseProtocol) WriteError(*jsonrpc.ID, *jsonrpc.ResponseError return p.responseErr } -type responseNotifyingProtocol struct { - ipc.Protocol - responseDone chan error +type closeNotifyingReadWriteCloser struct { + io.ReadWriteCloser + closed chan struct{} + once sync.Once } -func (p *responseNotifyingProtocol) WriteResponse(id *jsonrpc.ID, result any) error { - err := p.Protocol.WriteResponse(id, result) - p.responseDone <- err - return err +func (c *closeNotifyingReadWriteCloser) Close() error { + c.once.Do(func() { close(c.closed) }) + return c.ReadWriteCloser.Close() } func TestAsyncConnCallReturnsWhenPeerCloses(t *testing.T) { @@ -177,12 +177,11 @@ func TestAsyncConnRunReturnsWhenPeerClosesDuringRequest(t *testing.T) { close(handler.release) } }() - responseDone := make(chan error, 1) - protocol := &responseNotifyingProtocol{ - Protocol: ipc.NewJSONRPCProtocol(server), - responseDone: responseDone, + serverTransport := &closeNotifyingReadWriteCloser{ + ReadWriteCloser: server, + closed: make(chan struct{}), } - conn := ipc.NewAsyncConnWithProtocol(server, protocol, handler) + conn := ipc.NewAsyncConn(serverTransport, handler) runDone := make(chan error, 1) go func() { runDone <- conn.Run(t.Context()) }() @@ -205,13 +204,12 @@ func TestAsyncConnRunReturnsWhenPeerClosesDuringRequest(t *testing.T) { close(handler.release) select { - case responseErr := <-responseDone: - assert.Assert(t, responseErr != nil, "expected response write to fail") + case <-serverTransport.closed: _, err := conn.Call(t.Context(), "transform", nil) - assert.Assert(t, errors.Is(err, responseErr), "expected terminal response write error, got %v", err) + assert.ErrorContains(t, err, "ipc: failed to write response") err = conn.Notify(t.Context(), "changed", nil) - assert.Assert(t, errors.Is(err, responseErr), "expected terminal response write error, got %v", err) + assert.ErrorContains(t, err, "ipc: failed to write response") case <-time.After(time.Second): - t.Fatal("response write did not finish") + t.Fatal("connection did not close after response write failure") } } From cf75549298dc8416fa7b626660dd2d101435081e Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:35:20 -0700 Subject: [PATCH 6/7] Satisfy IPC teardown lint --- tsc/internal/ipc/conn_async.go | 1 + 1 file changed, 1 insertion(+) diff --git a/tsc/internal/ipc/conn_async.go b/tsc/internal/ipc/conn_async.go index 61c28003e38db..c602e6bcbec4f 100644 --- a/tsc/internal/ipc/conn_async.go +++ b/tsc/internal/ipc/conn_async.go @@ -80,6 +80,7 @@ func (c *AsyncConn) Run(ctx context.Context) (err error) { case requestErr := <-requestErrors: err = errors.Join(err, requestErr) default: + break } c.closePendingCalls(err) }() From 830444746187f6ec3cb965d33b56e5e709be83b8 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:50:00 -0700 Subject: [PATCH 7/7] Clarify empty IPC error case --- tsc/internal/ipc/conn_async.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tsc/internal/ipc/conn_async.go b/tsc/internal/ipc/conn_async.go index c602e6bcbec4f..48b6bfc074cbd 100644 --- a/tsc/internal/ipc/conn_async.go +++ b/tsc/internal/ipc/conn_async.go @@ -80,7 +80,7 @@ func (c *AsyncConn) Run(ctx context.Context) (err error) { case requestErr := <-requestErrors: err = errors.Join(err, requestErr) default: - break + // No request failed before the read loop exited. } c.closePendingCalls(err) }()