diff --git a/dotnet/src/Session.cs b/dotnet/src/Session.cs index e906554a..8798565f 100644 --- a/dotnet/src/Session.cs +++ b/dotnet/src/Session.cs @@ -44,7 +44,13 @@ namespace GitHub.Copilot.SDK; /// public partial class CopilotSession : IAsyncDisposable { - private readonly HashSet _eventHandlers = new(); + /// + /// Multicast delegate used as a thread-safe, insertion-ordered handler list. + /// The compiler-generated add/remove accessors use a lock-free CAS loop over the backing field. + /// Dispatch reads the field once (inherent snapshot, no allocation). + /// Expected handler count is small (typically 1–3), so Delegate.Combine/Remove cost is negligible. + /// + private event SessionEventHandler? _eventHandlers; private readonly Dictionary _toolHandlers = new(); private readonly JsonRpc _rpc; private volatile PermissionRequestHandler? _permissionHandler; @@ -243,8 +249,8 @@ void Handler(SessionEvent evt) /// public IDisposable On(SessionEventHandler handler) { - _eventHandlers.Add(handler); - return new ActionDisposable(() => _eventHandlers.Remove(handler)); + _eventHandlers += handler; + return new ActionDisposable(() => _eventHandlers -= handler); } /// @@ -256,11 +262,8 @@ public IDisposable On(SessionEventHandler handler) /// internal void DispatchEvent(SessionEvent sessionEvent) { - foreach (var handler in _eventHandlers.ToArray()) - { - // We allow handler exceptions to propagate so they are not lost - handler(sessionEvent); - } + // Reading the field once gives us a snapshot; delegates are immutable. + _eventHandlers?.Invoke(sessionEvent); } /// @@ -550,7 +553,7 @@ await InvokeRpcAsync( // Connection is broken or closed } - _eventHandlers.Clear(); + _eventHandlers = null; _toolHandlers.Clear(); _permissionHandler = null;