Skip to content

Commit 47f3640

Browse files
committed
fix(dotnet): serialize event enqueue barriers
1 parent de29350 commit 47f3640

2 files changed

Lines changed: 100 additions & 10 deletions

File tree

dotnet/src/Session.cs

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ private sealed record EventSubscription(Type EventType, Action<SessionEvent> Han
8181
private IReadOnlyList<OpenCanvasInstance> _openCanvases = Array.Empty<OpenCanvasInstance>();
8282

8383
private int _isDisposed;
84+
private readonly object _eventDispatchGate = new();
8485
private long _eventEnqueueVersion;
8586

8687
private abstract record EventDispatchItem;
@@ -417,8 +418,7 @@ async Task MonitorRuntimeCompletionAsync(CancellationToken waitCancellationToken
417418
activity = await Rpc.Metadata.ActivityAsync(waitCancellationToken).ConfigureAwait(false);
418419
if (!activity.HasActiveWork)
419420
{
420-
var stableEventVersion = Volatile.Read(ref _eventEnqueueVersion);
421-
await FlushEventDispatchAsync(waitCancellationToken).ConfigureAwait(false);
421+
var stableEventVersion = await FlushEventDispatchAsync(waitCancellationToken).ConfigureAwait(false);
422422
if (stableEventVersion != Volatile.Read(ref _eventEnqueueVersion))
423423
{
424424
continue;
@@ -583,27 +583,39 @@ internal void DispatchEvent(SessionEvent sessionEvent)
583583
// never completes (multi-client permission scenario).
584584
_ = HandleBroadcastEventAsync(sessionEvent);
585585

586-
// Queue the event for serial processing by user handlers. Increment first so
587-
// a completion monitor can detect an event even if this thread is preempted
588-
// before the channel write.
589-
Interlocked.Increment(ref _eventEnqueueVersion);
590-
_eventChannel.Writer.TryWrite(new EventItem(sessionEvent));
586+
// Queueing and publishing the new version share the same gate used to capture
587+
// a completion barrier's stable version. Publish first so a monitor whose
588+
// barrier is already queued cannot observe the old version after this event
589+
// is written behind it; the gate prevents a new barrier from splitting the
590+
// version update from the channel write.
591+
lock (_eventDispatchGate)
592+
{
593+
Interlocked.Increment(ref _eventEnqueueVersion);
594+
var queued = _eventChannel.Writer.TryWrite(new EventItem(sessionEvent));
595+
ObjectDisposedException.ThrowIf(!queued, this);
596+
}
591597
}
592598

593599
/// <summary>
594600
/// Waits until every event already queued for this session has been delivered to
595601
/// user handlers. Events arriving after the barrier remain queued behind it.
596602
/// </summary>
597-
private async Task FlushEventDispatchAsync(CancellationToken cancellationToken)
603+
private async Task<long> FlushEventDispatchAsync(CancellationToken cancellationToken)
598604
{
599605
var completion = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
600-
var queued = _eventChannel.Writer.TryWrite(new EventBarrier(completion));
601-
ObjectDisposedException.ThrowIf(!queued, this);
606+
long stableEventVersion;
607+
lock (_eventDispatchGate)
608+
{
609+
stableEventVersion = _eventEnqueueVersion;
610+
var queued = _eventChannel.Writer.TryWrite(new EventBarrier(completion));
611+
ObjectDisposedException.ThrowIf(!queued, this);
612+
}
602613

603614
using var registration = cancellationToken.Register(
604615
static state => ((TaskCompletionSource<bool>)state!).TrySetCanceled(),
605616
completion);
606617
await completion.Task.ConfigureAwait(false);
618+
return stableEventVersion;
607619
}
608620

609621
/// <summary>

dotnet/test/Unit/ClientSessionLifetimeTests.cs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,83 @@ public async Task SendAndWaitAsync_DroppedIdle_Fallback_Flushes_Event_Enqueued_D
587587
}
588588
}
589589

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+
590667
[MethodImpl(MethodImplOptions.NoInlining)]
591668
private static async Task<WeakReference<CopilotSession>> CreateDroppedSessionAsync(CopilotClient client)
592669
{
@@ -800,6 +877,7 @@ public void ConfigureEventEnqueueDuringFinalBarrier()
800877
_enqueueDuringFinalBarrier = true;
801878
}
802879

880+
803881
public void CompleteReactivatedActivity()
804882
{
805883
Volatile.Write(ref _hasActiveWork, false);

0 commit comments

Comments
 (0)