Skip to content

Commit 868dfc3

Browse files
committed
fix(go): close failed session event loops
1 parent 0c59943 commit 868dfc3

3 files changed

Lines changed: 160 additions & 17 deletions

File tree

go/client.go

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -935,6 +935,19 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses
935935
}
936936
req.SessionID = localSessionID
937937

938+
// unregisterSession removes only the session created by this call and stops
939+
// its event consumer. The latter is essential on CreateSession error paths:
940+
// newSession starts processEvents eagerly, and no caller receives the failed
941+
// session to disconnect it.
942+
unregisterSession := func(sessionID string, s *Session) {
943+
c.sessionsMux.Lock()
944+
if c.sessions[sessionID] == s {
945+
delete(c.sessions, sessionID)
946+
}
947+
c.sessionsMux.Unlock()
948+
s.closeEventChannel()
949+
}
950+
938951
// initializeSession creates the session, wires up handlers, and registers
939952
// it in the sessions map. Invoked from the read loop the instant the
940953
// session.create response arrives (synchronously, before the next
@@ -988,17 +1001,13 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses
9881001

9891002
if c.options.SessionFS != nil {
9901003
if config.CreateSessionFSProvider == nil {
991-
c.sessionsMux.Lock()
992-
delete(c.sessions, sessionID)
993-
c.sessionsMux.Unlock()
1004+
unregisterSession(sessionID, s)
9941005
return nil, fmt.Errorf("CreateSessionFSProvider is required in session config when SessionFS is enabled in client options")
9951006
}
9961007
provider := config.CreateSessionFSProvider(s)
9971008
if c.options.SessionFS.Capabilities != nil && c.options.SessionFS.Capabilities.Sqlite {
9981009
if _, ok := provider.(SessionFSSqliteProvider); !ok {
999-
c.sessionsMux.Lock()
1000-
delete(c.sessions, sessionID)
1001-
c.sessionsMux.Unlock()
1010+
unregisterSession(sessionID, s)
10021011
return nil, fmt.Errorf("SessionFS capabilities declare SQLite support but the provider does not implement SessionFSSqliteProvider")
10031012
}
10041013
}
@@ -1055,19 +1064,15 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses
10551064
result, err := c.client.RequestWithInlineResponse(ctx, "session.create", req, inlineCb)
10561065
if err != nil {
10571066
if registeredSessionID != "" {
1058-
c.sessionsMux.Lock()
1059-
delete(c.sessions, registeredSessionID)
1060-
c.sessionsMux.Unlock()
1067+
unregisterSession(registeredSessionID, session)
10611068
}
10621069
return nil, fmt.Errorf("failed to create session: %w", err)
10631070
}
10641071

10651072
var response createSessionResponse
10661073
if err := json.Unmarshal(result, &response); err != nil {
10671074
if registeredSessionID != "" {
1068-
c.sessionsMux.Lock()
1069-
delete(c.sessions, registeredSessionID)
1070-
c.sessionsMux.Unlock()
1075+
unregisterSession(registeredSessionID, session)
10711076
}
10721077
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
10731078
}
@@ -1077,9 +1082,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses
10771082
}
10781083

10791084
if localSessionID != "" && response.SessionID != "" && response.SessionID != localSessionID {
1080-
c.sessionsMux.Lock()
1081-
delete(c.sessions, registeredSessionID)
1082-
c.sessionsMux.Unlock()
1085+
unregisterSession(registeredSessionID, session)
10831086
return nil, fmt.Errorf("session.create returned sessionId %s but the caller requested %s", response.SessionID, localSessionID)
10841087
}
10851088
if config.OnMCPAuthRequest != nil {

go/client_test.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -689,6 +689,139 @@ func sessionIDFromParams(t *testing.T, params json.RawMessage) string {
689689
return decoded.SessionID
690690
}
691691

692+
func TestClient_CreateSessionFailureClosesRegisteredSession(t *testing.T) {
693+
tests := []struct {
694+
name string
695+
response func(string) (json.RawMessage, *jsonrpc2.Error)
696+
wantErrSub string
697+
}{
698+
{
699+
name: "RPC failure",
700+
response: func(string) (json.RawMessage, *jsonrpc2.Error) {
701+
return nil, &jsonrpc2.Error{Code: -32000, Message: "session creation failed"}
702+
},
703+
wantErrSub: "failed to create session",
704+
},
705+
{
706+
name: "invalid response",
707+
response: func(string) (json.RawMessage, *jsonrpc2.Error) {
708+
return json.RawMessage(`"invalid"`), nil
709+
},
710+
wantErrSub: "failed to unmarshal response",
711+
},
712+
{
713+
name: "session ID mismatch",
714+
response: func(string) (json.RawMessage, *jsonrpc2.Error) {
715+
return json.RawMessage(`{"sessionId":"different-session"}`), nil
716+
},
717+
wantErrSub: "but the caller requested failed-session",
718+
},
719+
}
720+
721+
for _, tt := range tests {
722+
t.Run(tt.name, func(t *testing.T) {
723+
rpcClient, server, _ := newRuntimeShutdownRpcPair(t)
724+
t.Cleanup(server.Stop)
725+
client := &Client{
726+
client: rpcClient,
727+
RPC: rpc.NewServerRPC(rpcClient),
728+
sessions: make(map[string]*Session),
729+
}
730+
731+
captured := make(chan *Session, 1)
732+
server.SetRequestHandler("session.create", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) {
733+
sessionID := sessionIDFromParams(t, params)
734+
client.sessionsMux.Lock()
735+
session := client.sessions[sessionID]
736+
client.sessionsMux.Unlock()
737+
captured <- session
738+
return tt.response(sessionID)
739+
})
740+
741+
_, err := client.CreateSession(t.Context(), &SessionConfig{SessionID: "failed-session"})
742+
if err == nil || !strings.Contains(err.Error(), tt.wantErrSub) {
743+
t.Fatalf("CreateSession error = %v, want substring %q", err, tt.wantErrSub)
744+
}
745+
746+
session := <-captured
747+
if session == nil {
748+
t.Fatal("session was not registered before session.create")
749+
}
750+
assertSessionEventChannelClosed(t, session)
751+
assertSessionNotRegistered(t, client, "failed-session")
752+
})
753+
}
754+
}
755+
756+
func TestClient_CreateSessionInitializationFailureClosesRegisteredSession(t *testing.T) {
757+
rpcClient, server, _ := newRuntimeShutdownRpcPair(t)
758+
t.Cleanup(server.Stop)
759+
client := &Client{
760+
client: rpcClient,
761+
RPC: rpc.NewServerRPC(rpcClient),
762+
sessions: make(map[string]*Session),
763+
options: ClientOptions{SessionFS: &SessionFSConfig{
764+
InitialWorkingDirectory: "/",
765+
SessionStatePath: "/session-state",
766+
Conventions: rpc.SessionFSSetProviderConventionsPosix,
767+
Capabilities: &SessionFSCapabilities{Sqlite: true},
768+
}},
769+
}
770+
771+
var captured *Session
772+
_, err := client.CreateSession(t.Context(), &SessionConfig{
773+
SessionID: "failed-session-fs",
774+
CreateSessionFSProvider: func(session *Session) SessionFSProvider {
775+
captured = session
776+
return noSQLiteSessionFSProvider{}
777+
},
778+
})
779+
if err == nil || !strings.Contains(err.Error(), "does not implement SessionFSSqliteProvider") {
780+
t.Fatalf("CreateSession error = %v, want SQLite provider validation error", err)
781+
}
782+
if captured == nil {
783+
t.Fatal("CreateSessionFSProvider did not receive the registered session")
784+
}
785+
assertSessionEventChannelClosed(t, captured)
786+
assertSessionNotRegistered(t, client, "failed-session-fs")
787+
}
788+
789+
func assertSessionEventChannelClosed(t *testing.T, session *Session) {
790+
t.Helper()
791+
select {
792+
case _, ok := <-session.eventCh:
793+
if ok {
794+
t.Fatal("session event channel is still open")
795+
}
796+
case <-time.After(time.Second):
797+
t.Fatal("timed out waiting for session event channel to close")
798+
}
799+
}
800+
801+
func assertSessionNotRegistered(t *testing.T, client *Client, sessionID string) {
802+
t.Helper()
803+
client.sessionsMux.Lock()
804+
defer client.sessionsMux.Unlock()
805+
if _, ok := client.sessions[sessionID]; ok {
806+
t.Fatalf("session %q is still registered", sessionID)
807+
}
808+
}
809+
810+
type noSQLiteSessionFSProvider struct{}
811+
812+
func (noSQLiteSessionFSProvider) ReadFile(string) (string, error) { return "", nil }
813+
func (noSQLiteSessionFSProvider) WriteFile(string, string, *int) error { return nil }
814+
func (noSQLiteSessionFSProvider) AppendFile(string, string, *int) error { return nil }
815+
func (noSQLiteSessionFSProvider) Exists(string) (bool, error) { return false, nil }
816+
func (noSQLiteSessionFSProvider) Stat(string) (*SessionFSFileInfo, error) { return nil, nil }
817+
func (noSQLiteSessionFSProvider) MakeDirectory(string, bool, *int) error { return nil }
818+
func (noSQLiteSessionFSProvider) ReadDirectory(string) ([]string, error) { return nil, nil }
819+
func (noSQLiteSessionFSProvider) ReadDirectoryWithTypes(string) ([]rpc.SessionFSReaddirWithTypesEntry, error) {
820+
return nil, nil
821+
}
822+
func (noSQLiteSessionFSProvider) Remove(string, bool, bool) error { return nil }
823+
func (noSQLiteSessionFSProvider) Rename(string, string) error { return nil }
824+
692825
func assertRuntimeShutdownNotCalled(t *testing.T, shutdownCalled <-chan struct{}) {
693826
t.Helper()
694827
select {

go/session.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ type Session struct {
9696
// eventCh serializes user event handler dispatch. dispatchEvent enqueues;
9797
// a single goroutine (processEvents) dequeues and invokes handlers in FIFO order.
9898
eventCh chan SessionEvent
99-
closeOnce sync.Once // guards eventCh close so Disconnect is safe to call more than once
99+
closeOnce sync.Once // guards eventCh close across disconnect and failed session creation
100100

101101
// RPC provides typed session-scoped RPC methods.
102102
RPC *rpc.SessionRPC
@@ -1421,6 +1421,13 @@ func (s *Session) processEvents() {
14211421
}
14221422
}
14231423

1424+
// closeEventChannel stops the session event consumer without making an RPC.
1425+
// CreateSession uses this when a locally registered session fails before it can
1426+
// be returned to the caller.
1427+
func (s *Session) closeEventChannel() {
1428+
s.closeOnce.Do(func() { close(s.eventCh) })
1429+
}
1430+
14241431
// handleBroadcastEvent handles broadcast request events by executing local handlers
14251432
// and responding via RPC. This implements the protocol v3 broadcast model where tool
14261433
// calls and permission requests are broadcast as session events to all clients.
@@ -1726,7 +1733,7 @@ func (s *Session) Disconnect() error {
17261733
return fmt.Errorf("failed to disconnect session: %w", err)
17271734
}
17281735

1729-
s.closeOnce.Do(func() { close(s.eventCh) })
1736+
s.closeEventChannel()
17301737

17311738
// Clear handlers
17321739
s.handlerMutex.Lock()

0 commit comments

Comments
 (0)