33 *--------------------------------------------------------------------------------------------*/
44
55using Microsoft . Extensions . AI ;
6+ using Microsoft . Extensions . Logging ;
67using StreamJsonRpc ;
78using System . Text . Json ;
89using System . Text . Json . Nodes ;
910using System . Text . Json . Serialization ;
11+ using System . Threading . Channels ;
1012using GitHub . Copilot . SDK . Rpc ;
1113
1214namespace GitHub . Copilot . SDK ;
@@ -55,19 +57,28 @@ public sealed partial class CopilotSession : IAsyncDisposable
5557 /// <summary>
5658 /// Multicast delegate used as a thread-safe, insertion-ordered handler list.
5759 /// The compiler-generated add/remove accessors use a lock-free CAS loop over the backing field.
58- /// Dispatch reads the field once (inherent snapshot, no allocation) .
60+ /// Invocation is serialized by the single-reader event channel consumer loop .
5961 /// Expected handler count is small (typically 1–3), so Delegate.Combine/Remove cost is negligible.
6062 /// </summary>
6163 private event SessionEventHandler ? EventHandlers ;
6264 private readonly Dictionary < string , AIFunction > _toolHandlers = [ ] ;
6365 private readonly JsonRpc _rpc ;
66+ private readonly ILogger _logger ;
6467 private volatile PermissionRequestHandler ? _permissionHandler ;
6568 private volatile UserInputHandler ? _userInputHandler ;
6669 private SessionHooks ? _hooks ;
6770 private readonly SemaphoreSlim _hooksLock = new ( 1 , 1 ) ;
6871 private SessionRpc ? _sessionRpc ;
6972 private int _isDisposed ;
7073
74+ /// <summary>
75+ /// Unbounded channel that serializes event dispatch. <see cref="DispatchEvent"/>
76+ /// enqueues; a single background consumer (<see cref="ProcessEventsAsync"/>) dequeues
77+ /// and invokes handlers one at a time, preserving arrival order.
78+ /// </summary>
79+ private readonly Channel < SessionEvent > _eventChannel = Channel . CreateUnbounded < SessionEvent > (
80+ new ( ) { SingleReader = true } ) ;
81+
7182 /// <summary>
7283 /// Gets the unique identifier for this session.
7384 /// </summary>
@@ -93,15 +104,18 @@ public sealed partial class CopilotSession : IAsyncDisposable
93104 /// </summary>
94105 /// <param name="sessionId">The unique identifier for this session.</param>
95106 /// <param name="rpc">The JSON-RPC connection to the Copilot CLI.</param>
107+ /// <param name="logger">Logger for diagnostics.</param>
96108 /// <param name="workspacePath">The workspace path if infinite sessions are enabled.</param>
97109 /// <remarks>
98110 /// This constructor is internal. Use <see cref="CopilotClient.CreateSessionAsync"/> to create sessions.
99111 /// </remarks>
100- internal CopilotSession ( string sessionId , JsonRpc rpc , string ? workspacePath = null )
112+ internal CopilotSession ( string sessionId , JsonRpc rpc , ILogger logger , string ? workspacePath = null )
101113 {
102114 SessionId = sessionId ;
103115 _rpc = rpc ;
116+ _logger = logger ;
104117 WorkspacePath = workspacePath ;
118+ _ = ProcessEventsAsync ( ) ;
105119 }
106120
107121 private Task < T > InvokeRpcAsync < T > ( string method , object ? [ ] ? args , CancellationToken cancellationToken )
@@ -236,7 +250,9 @@ void Handler(SessionEvent evt)
236250 /// Multiple handlers can be registered and will all receive events.
237251 /// </para>
238252 /// <para>
239- /// Handler exceptions are allowed to propagate so they are not lost.
253+ /// Handlers are invoked serially in event-arrival order on a background thread.
254+ /// A handler will never be called concurrently with itself or with other handlers
255+ /// on the same session.
240256 /// </para>
241257 /// </remarks>
242258 /// <example>
@@ -264,22 +280,56 @@ public IDisposable On(SessionEventHandler handler)
264280 }
265281
266282 /// <summary>
267- /// Dispatches an event to all registered handlers.
283+ /// Enqueues an event for serial dispatch to all registered handlers.
268284 /// </summary>
269285 /// <param name="sessionEvent">The session event to dispatch.</param>
270286 /// <remarks>
271- /// This method is internal. Handler exceptions are allowed to propagate so they are not lost.
287+ /// This method is non-blocking. The event is placed into an in-memory channel and
288+ /// processed by a single background consumer (<see cref="ProcessEventsAsync"/>),
289+ /// which guarantees handlers see events one at a time, in order.
272290 /// Broadcast request events (external_tool.requested, permission.requested) are handled
273291 /// internally before being forwarded to user handlers.
274292 /// </remarks>
275293 internal void DispatchEvent ( SessionEvent sessionEvent )
276294 {
277- // Handle broadcast request events (protocol v3) before dispatching to user handlers.
278- // Fire-and-forget: the response is sent asynchronously via RPC.
279- HandleBroadcastEventAsync ( sessionEvent ) ;
295+ if ( ! _eventChannel . Writer . TryWrite ( sessionEvent ) )
296+ {
297+ LogEventDropped ( sessionEvent . Type ) ;
298+ }
299+ }
300+
301+ /// <summary>
302+ /// Single-reader consumer loop that processes events from the channel.
303+ /// Ensures all user code — event handlers, tool handlers, permission handlers —
304+ /// is invoked serially and in FIFO order. Broadcast work (tool calls, permission
305+ /// requests) is awaited inline before dispatching to user handlers.
306+ /// </summary>
307+ private async Task ProcessEventsAsync ( )
308+ {
309+ await foreach ( var sessionEvent in _eventChannel . Reader . ReadAllAsync ( ) )
310+ {
311+ // Await broadcast work inline so tool/permission handlers are serialized
312+ // with everything else. If two tool requests arrive back-to-back, the second
313+ // won't start until the first completes.
314+ try
315+ {
316+ await HandleBroadcastEventAsync ( sessionEvent ) ;
317+ }
318+ catch ( Exception ex )
319+ {
320+ LogBroadcastHandlerError ( ex ) ;
321+ }
280322
281- // Reading the field once gives us a snapshot; delegates are immutable.
282- EventHandlers ? . Invoke ( sessionEvent ) ;
323+ // Invoke user handlers serially. Catch exceptions to keep the loop alive.
324+ try
325+ {
326+ EventHandlers ? . Invoke ( sessionEvent ) ;
327+ }
328+ catch ( Exception ex )
329+ {
330+ LogEventHandlerError ( ex ) ;
331+ }
332+ }
283333 }
284334
285335 /// <summary>
@@ -355,7 +405,7 @@ internal async Task<PermissionRequestResult> HandlePermissionRequestAsync(JsonEl
355405 /// Implements the protocol v3 broadcast model where tool calls and permission requests
356406 /// are broadcast as session events to all clients.
357407 /// </summary>
358- private async void HandleBroadcastEventAsync ( SessionEvent sessionEvent )
408+ private async Task HandleBroadcastEventAsync ( SessionEvent sessionEvent )
359409 {
360410 switch ( sessionEvent )
361411 {
@@ -703,6 +753,11 @@ public async Task LogAsync(string message, SessionLogRequestLevel? level = null,
703753 /// <returns>A task representing the dispose operation.</returns>
704754 /// <remarks>
705755 /// <para>
756+ /// The caller should ensure the session is idle (e.g., <see cref="SendAndWaitAsync"/>
757+ /// has returned) before disposing. If the session is not idle, in-flight event handlers
758+ /// or tool handlers may observe failures.
759+ /// </para>
760+ /// <para>
706761 /// Session state on disk (conversation history, planning state, artifacts) is
707762 /// preserved, so the conversation can be resumed later by calling
708763 /// <see cref="CopilotClient.ResumeSessionAsync"/> with the session ID. To
@@ -731,6 +786,12 @@ public async ValueTask DisposeAsync()
731786 return ;
732787 }
733788
789+ // Stop accepting new events. The consumer loop will exit naturally
790+ // after completing the current event (if any). The caller is expected
791+ // to have waited for the session to become idle before disposing;
792+ // if the session is not idle, in-flight handlers may see failures.
793+ _eventChannel . Writer . TryComplete ( ) ;
794+
734795 try
735796 {
736797 await InvokeRpcAsync < object > (
@@ -751,6 +812,15 @@ await InvokeRpcAsync<object>(
751812 _permissionHandler = null ;
752813 }
753814
815+ [ LoggerMessage ( Level = LogLevel . Error , Message = "Unhandled exception in broadcast event handler" ) ]
816+ private partial void LogBroadcastHandlerError ( Exception exception ) ;
817+
818+ [ LoggerMessage ( Level = LogLevel . Error , Message = "Unhandled exception in session event handler" ) ]
819+ private partial void LogEventHandlerError ( Exception exception ) ;
820+
821+ [ LoggerMessage ( Level = LogLevel . Warning , Message = "Event {EventType} dropped; session is shutting down" ) ]
822+ private partial void LogEventDropped ( string eventType ) ;
823+
754824 internal record SendMessageRequest
755825 {
756826 public string SessionId { get ; init ; } = string . Empty ;
0 commit comments