Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions dotnet/src/Session.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ public sealed partial class CopilotSession : IAsyncDisposable
private SessionRpc? _sessionRpc;
private int _isDisposed;

/// <summary>
/// Callback invoked when this session is disposed, so the owning client can
/// remove it from its session map. Set by <see cref="CopilotClient"/>.
/// </summary>
internal Action<string>? OnDisposed { get; set; }

/// <summary>
/// Channel that serializes event dispatch. <see cref="DispatchEvent"/> enqueues;
/// a single background consumer (<see cref="ProcessEventsAsync"/>) dequeues and
Expand Down Expand Up @@ -327,6 +333,10 @@ public IDisposable On(SessionEventHandler handler)
/// </remarks>
internal void DispatchEvent(SessionEvent sessionEvent)
{
// Guard: do not dispatch events to a disposed session.
if (Volatile.Read(ref _isDisposed) == 1)
return;

// Fire broadcast work concurrently (fire-and-forget with error logging).
// This is done outside the channel so broadcast handlers don't block the
// consumer loop — important when a secondary client's handler intentionally
Expand Down Expand Up @@ -1186,6 +1196,10 @@ public async ValueTask DisposeAsync()
return;
}

// Remove from client's session map before the RPC so that events
// arriving during the round-trip are never dispatched (fail-closed).
OnDisposed?.Invoke(SessionId);

_eventChannel.Writer.TryComplete();

try
Expand Down
41 changes: 41 additions & 0 deletions dotnet/test/PermissionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -248,4 +248,45 @@ await session.SendAsync(new MessageOptions

Assert.True(receivedToolCallId, "Should have received toolCallId in permission request");
}

/// <summary>
/// Validates that disposing a session does not affect subsequent sessions on the same client.
/// After session A is disposed, session B should handle permissions and complete tool calls normally.
/// This exercises the OnDisposed cleanup path that removes disposed sessions from the client map.
/// </summary>
[Fact]
public async Task Should_Handle_Permission_After_Prior_Session_Disposed()
{
// Create and use session A
var session1 = await CreateSessionAsync(new SessionConfig
{
OnPermissionRequest = (request, invocation) =>
Task.FromResult(new PermissionRequestResult { Kind = PermissionRequestResultKind.Approved })
});

await session1.SendAndWaitAsync(new MessageOptions { Prompt = "What is 1+1?" });

// Dispose session A — exercises the OnDisposed callback that removes it from the client map
await session1.DisposeAsync();

// Create session B on the same client — should work normally
var session2PermissionReceived = false;
var session2 = await CreateSessionAsync(new SessionConfig
{
OnPermissionRequest = (request, invocation) =>
{
session2PermissionReceived = true;
return Task.FromResult(new PermissionRequestResult { Kind = PermissionRequestResultKind.Approved });
}
});

// Session B should handle permissions and complete normally
await session2.SendAndWaitAsync(new MessageOptions
{
Prompt = "Run 'echo hello' for me"
}, timeout: TimeSpan.FromSeconds(15));

Assert.True(session2PermissionReceived,
"Session B's permission handler should fire normally after session A was disposed.");
}
}
10 changes: 10 additions & 0 deletions go/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,11 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses
c.sessionsMux.Lock()
c.sessions[sessionID] = session
c.sessionsMux.Unlock()
session.onDisposed = func(id string) {
c.sessionsMux.Lock()
delete(c.sessions, id)
c.sessionsMux.Unlock()
}

if c.options.SessionFs != nil {
if config.CreateSessionFsHandler == nil {
Expand Down Expand Up @@ -831,6 +836,11 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string,
c.sessionsMux.Lock()
c.sessions[sessionID] = session
c.sessionsMux.Unlock()
session.onDisposed = func(id string) {
c.sessionsMux.Lock()
delete(c.sessions, id)
c.sessionsMux.Unlock()
}

if c.options.SessionFs != nil {
if config.CreateSessionFsHandler == nil {
Expand Down
70 changes: 50 additions & 20 deletions go/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"sync"
"sync/atomic"
"time"

"github.com/github/copilot-sdk/go/internal/jsonrpc2"
Expand Down Expand Up @@ -74,6 +75,14 @@ type Session struct {
capabilities SessionCapabilities
capabilitiesMu sync.RWMutex

// disconnected is set atomically to 1 by Disconnect. Guards dispatchEvent
// so that events arriving after disconnect are silently dropped.
disconnected atomic.Int32

// onDisposed is called by Disconnect so the owning Client can remove the
// session from its sessions map before the RPC round-trip. Set by Client.
onDisposed func(sessionID string)

// eventCh serializes user event handler dispatch. dispatchEvent enqueues;
// a single goroutine (processEvents) dequeues and invokes handlers in FIFO order.
eventCh chan SessionEvent
Expand Down Expand Up @@ -841,6 +850,11 @@ func fromRPCElicitationResult(r *rpc.UIElicitationResponse) *ElicitationResult {
// are delivered by a single consumer goroutine (processEvents), guaranteeing
// serial, FIFO dispatch without blocking the read loop.
func (s *Session) dispatchEvent(event SessionEvent) {
// Guard: do not dispatch events to a disconnected session.
if s.disconnected.Load() != 0 {
return
}

go s.handleBroadcastEvent(event)

// Send to the event channel in a closure with a recover guard.
Expand Down Expand Up @@ -908,6 +922,8 @@ func (s *Session) handleBroadcastEvent(event SessionEvent) {
}
handler := s.getPermissionHandler()
if handler == nil {
// No handler registered (e.g. session disconnected or single-client
// headless mode). Another client will handle it.
return
}
s.executePermissionAndRespond(d.RequestID, d.PermissionRequest, handler)
Expand Down Expand Up @@ -1120,33 +1136,47 @@ func (s *Session) GetMessages(ctx context.Context) ([]SessionEvent, error) {
// log.Printf("Failed to disconnect session: %v", err)
// }
func (s *Session) Disconnect() error {
_, err := s.client.Request("session.destroy", sessionDestroyRequest{SessionID: s.SessionID})
if err != nil {
return fmt.Errorf("failed to disconnect session: %w", err)
// Mark disconnected before the RPC round-trip so that events arriving
// during the round-trip are never dispatched (fail-closed).
s.disconnected.Store(1)

// Remove from client's session map before the RPC so the client stops
// routing events to this session.
if s.onDisposed != nil {
Comment on lines +1141 to +1145
s.onDisposed(s.SessionID)
}

s.closeOnce.Do(func() { close(s.eventCh) })
// Ensure cleanup always runs even if session.destroy fails
defer func() {
s.closeOnce.Do(func() { close(s.eventCh) })

// Clear handlers
s.handlerMutex.Lock()
s.handlers = nil
s.handlerMutex.Unlock()
// Clear handlers
s.handlerMutex.Lock()
s.handlers = nil
s.handlerMutex.Unlock()

s.toolHandlersM.Lock()
s.toolHandlers = nil
s.toolHandlersM.Unlock()
s.toolHandlersM.Lock()
s.toolHandlers = nil
s.toolHandlersM.Unlock()

s.permissionMux.Lock()
s.permissionHandler = nil
s.permissionMux.Unlock()
s.permissionMux.Lock()
s.permissionHandler = nil
s.permissionMux.Unlock()

s.commandHandlersMu.Lock()
s.commandHandlers = nil
s.commandHandlersMu.Unlock()
s.commandHandlersMu.Lock()
s.commandHandlers = nil
s.commandHandlersMu.Unlock()

s.elicitationMu.Lock()
s.elicitationHandler = nil
s.elicitationMu.Unlock()
s.elicitationMu.Lock()
s.elicitationHandler = nil
s.elicitationMu.Unlock()
}()

// Try to destroy session on server
_, err := s.client.Request("session.destroy", sessionDestroyRequest{SessionID: s.SessionID})
if err != nil {
return fmt.Errorf("failed to disconnect session: %w", err)
}

return nil
}
Expand Down
2 changes: 2 additions & 0 deletions nodejs/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,7 @@ export class CopilotClient {
session.on(config.onEvent);
}
this.sessions.set(sessionId, session);
session.onDisposed = (id) => this.sessions.delete(id);
if (this.sessionFsConfig) {
if (config.createSessionFsHandler) {
session.clientSessionApis.sessionFs = createSessionFsAdapter(
Expand Down Expand Up @@ -852,6 +853,7 @@ export class CopilotClient {
session.on(config.onEvent);
}
this.sessions.set(sessionId, session);
session.onDisposed = (id) => this.sessions.delete(id);
if (this.sessionFsConfig) {
if (config.createSessionFsHandler) {
session.clientSessionApis.sessionFs = createSessionFsAdapter(
Expand Down
43 changes: 36 additions & 7 deletions nodejs/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,14 @@ export class CopilotSession {
private _rpc: ReturnType<typeof createSessionRpc> | null = null;
private traceContextProvider?: TraceContextProvider;
private _capabilities: SessionCapabilities = {};
private _disposed = false;

/**
* Callback invoked when this session is disposed, so the owning client can
* remove it from its session map. Set by {@link CopilotClient}.
* @internal
*/
onDisposed?: (sessionId: string) => void;

/** @internal Client session API handlers, populated by CopilotClient during create/resume. */
clientSessionApis: ClientSessionApiHandlers = {};
Expand Down Expand Up @@ -353,6 +361,9 @@ export class CopilotSession {
* @internal This method is for internal use by the SDK.
*/
_dispatchEvent(event: SessionEvent): void {
// Guard: do not dispatch events to a disposed session.
if (this._disposed) return;

// Handle broadcast request events internally (fire-and-forget)
this._handleBroadcastEvent(event);

Expand Down Expand Up @@ -421,6 +432,8 @@ export class CopilotSession {
if (this.permissionHandler) {
void this._executePermissionAndRespond(requestId, permissionRequest);
}
// No handler registered (e.g. session disposed or single-client headless mode).
// Another client will handle it (multi-client scenario) or the request will timeout.
} else if (event.type === "command.execute") {
const { requestId, commandName, command, args } = event.data as {
requestId: string;
Expand Down Expand Up @@ -965,13 +978,29 @@ export class CopilotSession {
* ```
*/
async disconnect(): Promise<void> {
await this.connection.sendRequest("session.destroy", {
sessionId: this.sessionId,
});
this.eventHandlers.clear();
this.typedEventHandlers.clear();
this.toolHandlers.clear();
this.permissionHandler = undefined;
if (this._disposed) return;
this._disposed = true;

// Remove from client's session map before the RPC so that events
// arriving during the round-trip are never dispatched (fail-closed).
this.onDisposed?.(this.sessionId);
Comment on lines +982 to +986

try {
await this.connection.sendRequest("session.destroy", {
sessionId: this.sessionId,
});
} catch {
// Connection already closed
} finally {
// Always clean up handlers
this.eventHandlers.clear();
this.typedEventHandlers.clear();
this.toolHandlers.clear();
this.commandHandlers.clear();
this.permissionHandler = undefined;
this.elicitationHandler = undefined;
this.userInputHandler = undefined;
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
models:
- claude-sonnet-4.5
conversations:
- messages:
- role: system
content: ${system}
- role: user
content: What is 1+1?
- role: assistant
content: 1+1 = 2
- messages:
- role: system
content: ${system}
- role: user
content: Run 'echo hello' for me
- role: assistant
tool_calls:
- id: toolcall_0
type: function
function:
name: report_intent
arguments: '{"intent":"Running echo command"}'
- id: toolcall_1
type: function
function:
name: ${shell}
arguments: '{"description":"Run echo hello","command":"echo hello"}'
- role: tool
tool_call_id: toolcall_0
content: Intent logged
- role: tool
tool_call_id: toolcall_1
content: |-
hello
<exited with exit code 0>
- role: assistant
content: "The command ran successfully and output: **hello**"