Created with AI assistance - Human reviewed.
Summary
On the non-cloud session path, Client.CreateSession leaks the session's processEvents goroutine whenever the session.create RPC (or its response handling) fails. The pre-registered session is removed from the client's session map on the error path without closing its event channel, so neither Client.Stop() nor a caller's own teardown can ever reap the goroutine. Each failed CreateSession call (and each retry) permanently leaks one goroutine plus its Session (which holds a 128-entry buffered channel) for the lifetime of the process.
Verified in v1.0.6 and still present in v1.0.9 (latest stable) — the relevant code is identical.
Root cause (Go SDK, v1.0.9 line numbers)
newSession eagerly starts the consumer goroutine: go s.processEvents() (go/session.go:389). processEvents runs for event := range s.eventCh and only returns once eventCh is closed.
eventCh is closed only by Session.Disconnect() → s.closeOnce.Do(func() { close(s.eventCh) }) (go/session.go:1724).
- For non-cloud sessions,
CreateSession generates a client-side localSessionID and pre-registers the session in c.sessions before issuing the RPC (initializeSession at go/client.go:915, invoked ~go/client.go:990) so that session-scoped requests emitted during session.create processing can be routed.
- Every
CreateSession error path after pre-registration then does only delete(c.sessions, registeredSessionID) and returns — it never calls Disconnect() / closes eventCh:
- RPC failure:
go/client.go:1029-1034
- response unmarshal failure:
go/client.go:1039-1044
- sessionId mismatch:
go/client.go:1051-1055
- (the
SessionFS validation paths inside initializeSession, go/client.go:964 and :972, have the same shape)
Client.Stop() disconnects only sessions still present in c.sessions. Because step 4 already removed the session from the map, Stop() cannot see it, and the goroutine blocks forever on eventCh.
Equivalent locations in v1.0.6: session.go:376, session.go:1662, client.go:920-946, client.go:436-445.
Impact
A long-running client that constructs a client/session per request accumulates one orphaned goroutine + Session per failed CreateSession. During a model/config or runtime outage — exactly when session.create fails and callers retry — goroutine and memory usage grow without bound until the process restarts. Successful sessions are unaffected (they stay in the map and are disconnected by Stop()).
The leaked resource is the in-process goroutine/Session; the CLI child process is still reaped by Stop(). So this is distinct from the child-process leaks in #1804 / #1381, and from the "disconnected sessions linger in the map" case that the (closed, unmerged) PR #1130 targeted.
Reproduction
- Build a
Client over stdio (non-cloud).
- Cause
session.create to fail (e.g. point at an unreachable/invalid model or induce a runtime error) so CreateSession returns an error.
- Call
Client.Stop().
- Observe via
runtime.NumGoroutine() / a goroutine dump that a processEvents goroutine remains parked on chan receive (s.eventCh). Repeat N times → N parked goroutines.
Suggested fix
On every CreateSession failure that occurs after the session has been pre-registered, close/disconnect the pre-registered session before returning (e.g. call session.Disconnect(), or at minimum s.closeOnce.Do(func() { close(s.eventCh) })) in addition to the existing delete(c.sessions, …). A defer-based cleanup that runs on any non-nil error return would cover all of the paths above (including the SessionFS ones) uniformly. PR #1130's OnDisposed wiring is a related, more general approach but was closed unmerged.
Environment
- SDK:
github.com/github/copilot-sdk/go v1.0.6 (repro confirmed in source through v1.0.9)
- Language: Go
- Transport: stdio (non-cloud / local session)
Created with AI assistance - Human reviewed.
Summary
On the non-cloud session path,
Client.CreateSessionleaks the session'sprocessEventsgoroutine whenever thesession.createRPC (or its response handling) fails. The pre-registered session is removed from the client's session map on the error path without closing its event channel, so neitherClient.Stop()nor a caller's own teardown can ever reap the goroutine. Each failedCreateSessioncall (and each retry) permanently leaks one goroutine plus itsSession(which holds a 128-entry buffered channel) for the lifetime of the process.Verified in v1.0.6 and still present in v1.0.9 (latest stable) — the relevant code is identical.
Root cause (Go SDK, v1.0.9 line numbers)
newSessioneagerly starts the consumer goroutine:go s.processEvents()(go/session.go:389).processEventsrunsfor event := range s.eventChand only returns onceeventChis closed.eventChis closed only bySession.Disconnect()→s.closeOnce.Do(func() { close(s.eventCh) })(go/session.go:1724).CreateSessiongenerates a client-sidelocalSessionIDand pre-registers the session inc.sessionsbefore issuing the RPC (initializeSessionatgo/client.go:915, invoked ~go/client.go:990) so that session-scoped requests emitted duringsession.createprocessing can be routed.CreateSessionerror path after pre-registration then does onlydelete(c.sessions, registeredSessionID)and returns — it never callsDisconnect()/ closeseventCh:go/client.go:1029-1034go/client.go:1039-1044go/client.go:1051-1055SessionFSvalidation paths insideinitializeSession,go/client.go:964and:972, have the same shape)Client.Stop()disconnects only sessions still present inc.sessions. Because step 4 already removed the session from the map,Stop()cannot see it, and the goroutine blocks forever oneventCh.Equivalent locations in v1.0.6:
session.go:376,session.go:1662,client.go:920-946,client.go:436-445.Impact
A long-running client that constructs a client/session per request accumulates one orphaned goroutine +
Sessionper failedCreateSession. During a model/config or runtime outage — exactly whensession.createfails and callers retry — goroutine and memory usage grow without bound until the process restarts. Successful sessions are unaffected (they stay in the map and are disconnected byStop()).The leaked resource is the in-process goroutine/
Session; the CLI child process is still reaped byStop(). So this is distinct from the child-process leaks in #1804 / #1381, and from the "disconnected sessions linger in the map" case that the (closed, unmerged) PR #1130 targeted.Reproduction
Clientover stdio (non-cloud).session.createto fail (e.g. point at an unreachable/invalid model or induce a runtime error) soCreateSessionreturns an error.Client.Stop().runtime.NumGoroutine()/ a goroutine dump that aprocessEventsgoroutine remains parked onchan receive (s.eventCh). Repeat N times → N parked goroutines.Suggested fix
On every
CreateSessionfailure that occurs after the session has been pre-registered, close/disconnect the pre-registered session before returning (e.g. callsession.Disconnect(), or at minimums.closeOnce.Do(func() { close(s.eventCh) })) in addition to the existingdelete(c.sessions, …). Adefer-based cleanup that runs on any non-nil error return would cover all of the paths above (including theSessionFSones) uniformly. PR #1130'sOnDisposedwiring is a related, more general approach but was closed unmerged.Environment
github.com/github/copilot-sdk/gov1.0.6 (repro confirmed in source through v1.0.9)