Skip to content

fix: clean up disposed sessions from client map to prevent memory leak#1130

Closed
PureWeen wants to merge 5 commits intogithub:mainfrom
PureWeen:fix/permission-callback-disposed-session
Closed

fix: clean up disposed sessions from client map to prevent memory leak#1130
PureWeen wants to merge 5 commits intogithub:mainfrom
PureWeen:fix/permission-callback-disposed-session

Conversation

@PureWeen
Copy link
Copy Markdown
Contributor

@PureWeen PureWeen commented Apr 23, 2026

Summary

When a CopilotSession is disposed via DisposeAsync() (or disconnect()/Disconnect() in Node.js/Go), the session is not removed from the owning Client._sessions map. Only StopAsync/ForceStopAsync clear the map. This means disposed sessions accumulate in memory for the lifetime of the client — a memory leak.

Additionally, during the async disposal window (between setting the disposed flag and completing the session.destroy RPC round-trip), events can still be dispatched to the session even though its handlers are being torn down.

Changes

All three SDKs (.NET, Node.js, Go):

  1. OnDisposed callback — New property on Session that the owning Client wires to remove the session from its _sessions map. Called at the start of disposal (before the session.destroy RPC), ensuring events arriving during the round-trip are never dispatched (fail-closed).

  2. Disposed guard in DispatchEvent — Checks the disposed flag and returns early, preventing race-condition event delivery during async disposal.

  3. Guaranteed handler cleanup — Node.js wraps session.destroy in try/finally, Go uses defer, ensuring event handlers are always cleared even if the RPC fails. (.NET already had proper try/catch in DisposeAsync.)

What this does NOT fix

Permission events (permission.requested) are dispatched per-session by sessionId — they are NOT broadcast across all sessions within a client. A disposed session A can never intercept permission events meant for session B. This PR does not change permission routing behavior; it only prevents memory leaks and eliminates a small race window.

Testing

  • E2E test: Should_Handle_Permission_After_Prior_Session_Disposed — creates session A, disposes it, creates session B on the same client, and verifies session B handles permissions normally. Exercises the OnDisposed cleanup path.

Files Changed

SDK Files
.NET dotnet/src/Session.cs, dotnet/src/Client.cs
Node.js nodejs/src/session.ts, nodejs/src/client.ts
Go go/session.go, go/client.go
Tests dotnet/test/PermissionTests.cs, test/snapshots/permissions/should_handle_permission_after_prior_session_disposed.yaml

PureWeen and others added 4 commits April 23, 2026 13:28
…sessions

When a session is disposed during reconnection in persistent headless mode:
1. DispatchEvent could deliver events to disposed sessions (handler already nulled)
2. Session remained in Client._sessions dictionary after disposal
3. Permission requests to disposed sessions silently returned (no denial sent)
4. In single-client mode, this caused CLI to hang forever waiting for permission response

Three-part fix:
- Add disposed guard in DispatchEvent to prevent event delivery after disposal
- Add OnDisposed callback wired in Client to remove session from _sessions on dispose
- Send explicit PermissionDenied when handler is null instead of silent return

Includes 8 tests proving the bug exists and verifying the fix.

Fixes PureWeen/PolyPilot#300

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The repo's copilot-instructions.md explicitly bans IVT:
'Never add InternalsVisibleTo to any project file when writing tests.
Tests must only access public APIs.'

The production fix (disposed guard, OnDisposed callback, explicit denial)
is all internal implementation — no IVT needed for the fix itself. The
unit tests that directly constructed internal types and called internal
methods have been removed.

The fix is best validated through:
1. Code review of the defensive changes in Session.cs and Client.cs
2. E2E integration testing of the reconnection scenario
3. Manual testing in PolyPilot with persistent headless mode

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Port the 3-part session disposal fix to all SDK platforms:

Node.js (nodejs/src/session.ts, nodejs/src/client.ts):
- Disposed guard in _dispatchEvent
- onDisposed callback wired in createSession/resumeSession
- Explicit denial when permissionHandler is undefined
- Also clears commandHandlers, elicitationHandler, userInputHandler in disconnect()

Go (go/session.go, go/client.go):
- Disposed guard in dispatchEvent using atomic.Int32
- onDisposed callback wired in CreateSession/ResumeSessionWithOptions
- Explicit denial via RPC.Permissions.HandlePendingPermissionRequest

All three platforms now have identical behavior: disposed sessions are
removed from the client map, late events are dropped, and missing
permission handlers send explicit denials instead of hanging the CLI.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Tests the fix for issue github#300: when session A is disposed, creating session B
and triggering a permission-requiring tool call should route the permission
event to session B (not the disposed session A).

Uses the existing E2E snapshot harness with a new YAML snapshot that has two
conversations: one for session A's simple prompt and one for session B's
tool-using prompt.

Key assertions:
- Session B's permission handler fires (session completes successfully)
- Session A's handler does NOT fire (disposed session is removed from map)
- 15s timeout ensures regression (infinite hang) fails fast in CI

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings April 23, 2026 20:30
@PureWeen PureWeen requested a review from a team as a code owner April 23, 2026 20:30
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a deterministic hang where permission.requested broadcasts can be routed to a disposed session that no longer has a permission handler, causing the CLI to wait indefinitely for a decision (notably in single-client headless/persistent-server scenarios).

Changes:

  • Adds disposal/disconnect guards to prevent event dispatch into disposed sessions (defense-in-depth).
  • Introduces an “on disposed” callback so clients remove sessions from their session maps immediately at the start of disposal.
  • Sends an explicit permission denial when no handler is available (Node.js/Go, and now .NET) to avoid indefinite hangs; adds a .NET E2E regression test + snapshot.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
test/snapshots/permissions/should_handle_permission_after_prior_session_disposed.yaml New replay snapshot for the disposal/permission regression scenario
nodejs/src/session.ts Disposed guard, onDisposed callback hook, explicit denial when handler missing, expanded cleanup
nodejs/src/client.ts Wires session.onDisposed to remove sessions from the client map
go/session.go Disconnected guard (atomic), onDisposed callback hook, explicit denial when handler missing
go/client.go Wires session.onDisposed to remove sessions from the client map
dotnet/test/PermissionTests.cs Adds E2E regression test that reproduces the hang and validates correct routing post-disposal
dotnet/src/Session.cs Disposed guard, OnDisposed callback hook, explicit denial when handler missing
dotnet/src/Client.cs Wires session.OnDisposed to remove sessions from the client map

Comment thread nodejs/src/session.ts Outdated
Comment on lines +993 to +1008
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);

await this.connection.sendRequest("session.destroy", {
sessionId: this.sessionId,
});
this.eventHandlers.clear();
this.typedEventHandlers.clear();
this.toolHandlers.clear();
this.commandHandlers.clear();
this.permissionHandler = undefined;
this.elicitationHandler = undefined;
this.userInputHandler = undefined;
Comment thread go/session.go
Comment on lines +1148 to +1152
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 thread nodejs/src/session.ts
Comment on lines +993 to +997
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);
The original fix incorrectly assumed permission events are broadcast across
all sessions within a client. In reality, they are dispatched per-session by
sessionId. A disposed session can never intercept events meant for another.

Changes:
- Remove explicit denial RPC in all 3 SDKs (.NET, Node.js, Go) — the null
  handler return is intentional for multi-client scenarios
- Add try/finally (Node.js) and defer (Go) to guarantee handler cleanup
  even if session.destroy RPC fails
- Rewrite E2E test with accurate description (cleanup validation, not
  permission routing fix)

What remains (the actual fix):
- OnDisposed callback removes session from Client._sessions on disposal
  (prevents memory leak — sessions accumulated without cleanup)
- Disposed guard in DispatchEvent prevents race-condition event delivery
  during async disposal

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@PureWeen PureWeen changed the title fix: prevent permission callback loss after session disposal fix: clean up disposed sessions from client map to prevent memory leak Apr 24, 2026
@PureWeen PureWeen marked this pull request as draft April 24, 2026 13:52
@PureWeen PureWeen closed this Apr 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants