1010using System . Runtime . CompilerServices ;
1111using System . Text ;
1212using System . Text . Json ;
13+ using Microsoft . Extensions . Logging ;
1314using Xunit ;
1415
1516namespace GitHub . Copilot . Test . Unit ;
@@ -18,6 +19,40 @@ public sealed class ClientSessionLifetimeTests
1819{
1920 private sealed record RpcRequestRecord ( string Method , JsonElement Params ) ;
2021
22+ private sealed class RecordingLogger : ILogger
23+ {
24+ private readonly object _gate = new ( ) ;
25+ private readonly List < string > _messages = [ ] ;
26+
27+ public IReadOnlyList < string > Messages
28+ {
29+ get
30+ {
31+ lock ( _gate )
32+ {
33+ return _messages . ToArray ( ) ;
34+ }
35+ }
36+ }
37+
38+ public IDisposable ? BeginScope < TState > ( TState state ) where TState : notnull => null ;
39+
40+ public bool IsEnabled ( LogLevel logLevel ) => true ;
41+
42+ public void Log < TState > (
43+ LogLevel logLevel ,
44+ EventId eventId ,
45+ TState state ,
46+ Exception ? exception ,
47+ Func < TState , Exception ? , string > formatter )
48+ {
49+ lock ( _gate )
50+ {
51+ _messages . Add ( formatter ( state , exception ) ) ;
52+ }
53+ }
54+ }
55+
2156 [ Fact ]
2257 public async Task StopAsync_Requests_Runtime_Shutdown_For_Owned_Process ( )
2358 {
@@ -141,6 +176,39 @@ public async Task Disposing_Session_Remains_Rooted_Until_Destroy_Completes()
141176 AssertSessionCount ( client , sessions : 0 ) ;
142177 }
143178
179+ [ Fact ]
180+ public async Task Disposing_Session_Ignores_Racing_Inbound_Event ( )
181+ {
182+ await using var server = await FakeCopilotServer . StartAsync ( ) ;
183+ server . DelayDestroy ( ) ;
184+ var logger = new RecordingLogger ( ) ;
185+ await using var client = new CopilotClient ( new CopilotClientOptions
186+ {
187+ Connection = RuntimeConnection . ForUri ( server . Url ) ,
188+ Logger = logger
189+ } ) ;
190+
191+ var session = await client . CreateSessionAsync ( new SessionConfig
192+ {
193+ OnPermissionRequest = PermissionHandler . ApproveAll
194+ } ) ;
195+
196+ var disposeTask = session . DisposeAsync ( ) . AsTask ( ) ;
197+ await server . DestroyStarted ;
198+ await server . EmitTurnEndEventAsync ( "event-during-destroy" ) ;
199+ await Task . Delay ( 100 ) ;
200+ server . CompleteDestroy ( ) ;
201+ await disposeTask ;
202+
203+ await using var nextSession = await client . CreateSessionAsync ( new SessionConfig
204+ {
205+ OnPermissionRequest = PermissionHandler . ApproveAll
206+ } ) ;
207+ Assert . NotNull ( nextSession ) ;
208+ Assert . DoesNotContain ( logger . Messages , message =>
209+ message . Contains ( "Error handling JSON-RPC method session.event" , StringComparison . Ordinal ) ) ;
210+ }
211+
144212 [ Fact ]
145213 public async Task StopAsync_Removes_Rooted_Sessions ( )
146214 {
@@ -537,7 +605,7 @@ public async Task SendAndWaitAsync_DroppedIdle_Fallback_Rechecks_Activity_After_
537605 }
538606
539607 [ Fact ]
540- public async Task SendAndWaitAsync_DroppedIdle_Fallback_Flushes_Event_Enqueued_During_Final_Barrier ( )
608+ public async Task SendAndWaitAsync_DroppedIdle_Fallback_Flushes_Inbound_Event_Enqueued_During_Final_Barrier ( )
541609 {
542610 await using var server = await FakeCopilotServer . StartAsync ( ) ;
543611 server . ConfigureEventEnqueueDuringFinalBarrier ( ) ;
@@ -553,10 +621,7 @@ public async Task SendAndWaitAsync_DroppedIdle_Fallback_Flushes_Event_Enqueued_D
553621 {
554622 if ( evt . Data . TurnId == "activity-reactivation-barrier" )
555623 {
556- DispatchEvent ( session , new AssistantTurnEndEvent
557- {
558- Data = new AssistantTurnEndData { TurnId = "queued-during-final-barrier" }
559- } ) ;
624+ server . EmitTurnEndEventAsync ( "queued-during-final-barrier" ) . GetAwaiter ( ) . GetResult ( ) ;
560625 }
561626 else if ( evt . Data . TurnId == "queued-during-final-barrier" )
562627 {
@@ -587,83 +652,6 @@ public async Task SendAndWaitAsync_DroppedIdle_Fallback_Flushes_Event_Enqueued_D
587652 }
588653 }
589654
590- [ Fact ]
591- public async Task SendAndWaitAsync_DroppedIdle_Fallback_Serializes_Concurrent_Enqueue_With_Final_Barrier ( )
592- {
593- await using var server = await FakeCopilotServer . StartAsync ( ) ;
594- server . ConfigureEventEnqueueDuringFinalBarrier ( ) ;
595- await using var client = new CopilotClient ( new CopilotClientOptions { Connection = RuntimeConnection . ForUri ( server . Url ) } ) ;
596- await using var session = await client . CreateSessionAsync ( new SessionConfig
597- {
598- OnPermissionRequest = PermissionHandler . ApproveAll
599- } ) ;
600-
601- var dispatchGate = typeof ( CopilotSession ) . GetField ( "_eventDispatchGate" , BindingFlags . Instance | BindingFlags . NonPublic )
602- ? . GetValue ( session )
603- ?? throw new InvalidOperationException ( "Event dispatch synchronization gate was not found." ) ;
604- var enqueueStarted = new TaskCompletionSource ( TaskCreationOptions . RunContinuationsAsynchronously ) ;
605- var releaseEnqueue = new TaskCompletionSource ( TaskCreationOptions . RunContinuationsAsynchronously ) ;
606- var queuedHandlerStarted = new TaskCompletionSource ( TaskCreationOptions . RunContinuationsAsynchronously ) ;
607- var releaseQueuedHandler = new TaskCompletionSource ( TaskCreationOptions . RunContinuationsAsynchronously ) ;
608- Task ? enqueueTask = null ;
609- using var subscription = session . On < AssistantTurnEndEvent > ( evt =>
610- {
611- if ( evt . Data . TurnId == "activity-reactivation-barrier" )
612- {
613- lock ( dispatchGate )
614- {
615- var dispatchAttempted = new TaskCompletionSource ( TaskCreationOptions . RunContinuationsAsynchronously ) ;
616- var task = Task . Run ( ( ) =>
617- {
618- dispatchAttempted . TrySetResult ( ) ;
619- DispatchEvent ( session , new AssistantTurnEndEvent
620- {
621- Data = new AssistantTurnEndData { TurnId = "queued-after-concurrent-enqueue" }
622- } ) ;
623- } ) ;
624- enqueueTask = task ;
625- dispatchAttempted . Task . GetAwaiter ( ) . GetResult ( ) ;
626- enqueueStarted . TrySetResult ( ) ;
627- releaseEnqueue . Task . GetAwaiter ( ) . GetResult ( ) ;
628- }
629- }
630- else if ( evt . Data . TurnId == "queued-after-concurrent-enqueue" )
631- {
632- queuedHandlerStarted . TrySetResult ( ) ;
633- releaseQueuedHandler . Task . GetAwaiter ( ) . GetResult ( ) ;
634- }
635- } ) ;
636-
637- var completionTask = session . SendAndWaitAsync (
638- new MessageOptions { Prompt = "serialize a concurrent enqueue with the final barrier" } ,
639- timeout : TimeSpan . FromSeconds ( 5 ) ) ;
640-
641- try
642- {
643- await enqueueStarted . Task . WaitAsync ( TimeSpan . FromSeconds ( 2 ) ) ;
644- await Task . Delay ( 200 ) ;
645- Assert . False ( enqueueTask ! . IsCompleted , "DispatchEvent must be blocked by the shared enqueue/barrier gate." ) ;
646- Assert . False ( completionTask . IsCompleted , "Completion must not cross an event enqueue that is contending with the final barrier." ) ;
647-
648- releaseEnqueue . TrySetResult ( ) ;
649- await enqueueTask ! . WaitAsync ( TimeSpan . FromSeconds ( 2 ) ) ;
650- var first = await Task . WhenAny ( queuedHandlerStarted . Task , completionTask )
651- . WaitAsync ( TimeSpan . FromSeconds ( 2 ) ) ;
652- Assert . Same ( queuedHandlerStarted . Task , first ) ;
653- Assert . False ( completionTask . IsCompleted , "Completion must wait for the in-flight event's handler to cross the FIFO barrier." ) ;
654-
655- releaseQueuedHandler . TrySetResult ( ) ;
656- var response = await completionTask ;
657- Assert . Equal ( "completed response" , response ? . Data . Content ) ;
658- }
659- finally
660- {
661- releaseEnqueue . TrySetResult ( ) ;
662- releaseQueuedHandler . TrySetResult ( ) ;
663- }
664- }
665-
666-
667655 [ MethodImpl ( MethodImplOptions . NoInlining ) ]
668656 private static async Task < WeakReference < CopilotSession > > CreateDroppedSessionAsync ( CopilotClient client )
669657 {
@@ -767,6 +755,7 @@ private sealed class FakeCopilotServer : IAsyncDisposable
767755 private readonly Task _serverTask ;
768756 private readonly List < RpcRequestRecord > _requests = [ ] ;
769757 private readonly object _requestsLock = new ( ) ;
758+ private Stream ? _stream ;
770759 private string ? _lastSessionId ;
771760 private bool _delayDestroy ;
772761 private bool _failRuntimeShutdown ;
@@ -883,6 +872,30 @@ public void CompleteReactivatedActivity()
883872 Volatile . Write ( ref _hasActiveWork , false ) ;
884873 }
885874
875+ public Task EmitTurnEndEventAsync ( string turnId )
876+ {
877+ var stream = _stream ?? throw new InvalidOperationException ( "The test transport is not connected." ) ;
878+ return WriteMessageAsync ( stream , new Dictionary < string , object ? >
879+ {
880+ [ "jsonrpc" ] = "2.0" ,
881+ [ "method" ] = "session.event" ,
882+ [ "params" ] = new Dictionary < string , object ? >
883+ {
884+ [ "sessionId" ] = _lastSessionId ,
885+ [ "event" ] = new Dictionary < string , object ? >
886+ {
887+ [ "type" ] = "assistant.turn_end" ,
888+ [ "id" ] = Guid . NewGuid ( ) . ToString ( ) ,
889+ [ "timestamp" ] = DateTimeOffset . UtcNow . ToString ( "O" ) ,
890+ [ "data" ] = new Dictionary < string , object ? >
891+ {
892+ [ "turnId" ] = turnId
893+ }
894+ }
895+ }
896+ } , _cts . Token ) ;
897+ }
898+
886899 public async ValueTask DisposeAsync ( )
887900 {
888901 _allowDestroy . TrySetResult ( ) ;
@@ -905,16 +918,24 @@ private async Task RunAsync()
905918 {
906919 using var tcpClient = await _listener . AcceptTcpClientAsync ( _cts . Token ) ;
907920 using var stream = tcpClient . GetStream ( ) ;
921+ _stream = stream ;
908922
909- while ( ! _cts . Token . IsCancellationRequested )
923+ try
910924 {
911- using var request = await ReadMessageAsync ( stream , _cts . Token ) ;
912- if ( request is null )
925+ while ( ! _cts . Token . IsCancellationRequested )
913926 {
914- return ;
915- }
927+ using var request = await ReadMessageAsync ( stream , _cts . Token ) ;
928+ if ( request is null )
929+ {
930+ return ;
931+ }
916932
917- await HandleRequestAsync ( stream , request . RootElement , _cts . Token ) ;
933+ await HandleRequestAsync ( stream , request . RootElement , _cts . Token ) ;
934+ }
935+ }
936+ finally
937+ {
938+ _stream = null ;
918939 }
919940 }
920941
0 commit comments