Skip to content

Commit 998f42d

Browse files
committed
fix(dotnet): harden event barrier lifecycle
1 parent 59c0702 commit 998f42d

2 files changed

Lines changed: 122 additions & 90 deletions

File tree

dotnet/src/Session.cs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -590,9 +590,17 @@ internal void DispatchEvent(SessionEvent sessionEvent)
590590
// version update from the channel write.
591591
lock (_eventDispatchGate)
592592
{
593+
// DisposeAsync completes the channel under this same gate. Notifications
594+
// that race with session.destroy are intentionally ignored, matching the
595+
// pre-barrier behavior instead of failing the JSON-RPC notification pump.
596+
if (Volatile.Read(ref _isDisposed) != 0)
597+
{
598+
return;
599+
}
600+
593601
Interlocked.Increment(ref _eventEnqueueVersion);
594602
var queued = _eventChannel.Writer.TryWrite(new EventItem(sessionEvent));
595-
ObjectDisposedException.ThrowIf(!queued, this);
603+
Debug.Assert(queued, "The event channel cannot complete outside the dispatch gate.");
596604
}
597605
}
598606

@@ -2033,7 +2041,10 @@ public async ValueTask DisposeAsync()
20332041
return;
20342042
}
20352043

2036-
_eventChannel.Writer.TryComplete();
2044+
lock (_eventDispatchGate)
2045+
{
2046+
_eventChannel.Writer.TryComplete();
2047+
}
20372048

20382049
try
20392050
{

dotnet/test/Unit/ClientSessionLifetimeTests.cs

Lines changed: 109 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
using System.Runtime.CompilerServices;
1111
using System.Text;
1212
using System.Text.Json;
13+
using Microsoft.Extensions.Logging;
1314
using Xunit;
1415

1516
namespace 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

Comments
 (0)