[C#] Use event delegate for thread-safe, insertion-ordered event handler dispatch#624
Conversation
There was a problem hiding this comment.
Pull request overview
This PR improves the thread-safety of .NET session event handler registration/dispatch in CopilotSession by replacing a non-thread-safe HashSet with a ConcurrentDictionary used as a set, reducing the risk of collection corruption under concurrent subscribe/unsubscribe while events are being dispatched.
Changes:
- Replaced
_eventHandlersfromHashSet<SessionEventHandler>toConcurrentDictionary<SessionEventHandler, bool>. - Updated
On(...)to useTryAdd/TryRemovefor thread-safe subscription management. - Updated
DispatchEvent(...)to iterate the concurrent collection without the prior.ToArray()snapshot.
Comments suppressed due to low confidence (2)
dotnet/src/Session.cs:265
ConcurrentDictionaryenumeration is thread-safe but is not guaranteed to be a stable snapshot; concurrentOn(...)/ disposal during dispatch can cause handlers to be skipped or newly-added handlers to be invoked mid-dispatch. If dispatch semantics should be consistent per event, prefer enumerating a snapshot (e.g.,_eventHandlers.Keyssnapshot) or explicitly snapshotting keys before invoking handlers.
internal void DispatchEvent(SessionEvent sessionEvent)
{
foreach (var (handler, _) in _eventHandlers)
{
// We allow handler exceptions to propagate so they are not lost
handler(sessionEvent);
dotnet/src/Session.cs:250
- This change addresses a concurrency bug, but there’s no regression test exercising concurrent subscribe/unsubscribe while
DispatchEventruns. Adding a targeted test (even a bounded stress test) would help catch future regressions and validate the thread-safety guarantee ofOn(...)+ disposal during event dispatch.
public IDisposable On(SessionEventHandler handler)
{
_eventHandlers.TryAdd(handler, true);
return new OnDisposeCall(() => _eventHandlers.TryRemove(handler, out _));
Cross-SDK Consistency Review: Thread SafetyThis PR correctly addresses a thread-safety issue in the .NET SDK by using Current Thread Safety Status
Consistency GapThe Node.js/TypeScript SDK (
While JavaScript's single-threaded event loop makes this less likely to cause issues in typical use cases, the SDK could still experience race conditions if:
RecommendationConsider applying a similar thread-safety improvement to the Node.js SDK. Options include:
This would ensure consistent thread-safety guarantees across all SDK implementations. Note: This PR itself is excellent and correctly fixes the .NET SDK. The Node.js SDK issue is pre-existing but worth addressing for cross-SDK consistency.
|
|
@stephentoub I'm curious about whether you think |
Cross-SDK Consistency Review ✅I've reviewed this PR for consistency across all SDK implementations (Node.js/TypeScript, Python, Go, and .NET). SummaryThis PR appropriately addresses a thread-safety issue specific to .NET. The change from Analysis by SDK✅ .NET (this PR):
✅ Go:
✅ Python:
✅ Node.js/TypeScript:
ConclusionEach SDK uses thread-safety mechanisms appropriate to its language and runtime:
No cross-SDK consistency issues detected. The changes maintain appropriate patterns for each language's concurrency model.
|
I think we should be clear about what is safe and what isn't. Right now, if someone does this: session.SendAsync(...);
session.On(anotherhandler);that's not safe even though they're only using the instance themselves serially. That's an example of the case this PR is trying to address. If we don't think that's valid use, that's fine, but we should then document what's safe and what's not, e.g. you can't use session.On or dispose of an IDisposable returned from On while a session is in-use, only when it's idle. |
…dler dispatch Replace HashSet<SessionEventHandler> with a private event (multicast delegate). The compiler-generated add/remove accessors use a lock-free CAS loop, dispatch reads the field once for an inherent snapshot, and invocation order matches registration order. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
0406c3a to
993cc71
Compare
|
@stephentoub Totally makes sense - thanks! Yes, the developer can't control when events fire, and likely can't stop them from happening on a different thread, so anything that could conflict with event dispatch does need to be thread-safe. |
_eventHandlerswas aHashSet<SessionEventHandler>, which is not thread-safe. Concurrent calls toOn(...)or disposal of subscriptions whileDispatchEventwas iterating could corrupt the set. The.ToArray()snapshot inDispatchEventpartially mitigated iteration safety but didn't protect theHashSetitself from concurrent mutation, and it didn't guarantee invocation order.Changes
Replace
HashSet<SessionEventHandler>with aprivate event SessionEventHandler?(multicast delegate):+=/-=accessors use a lock-freeInterlocked.CompareExchangeCAS loop.Delegate.Combineappends to the invocation list, so handlers are always called in registration order. This eliminates a class of bugs where handler-ordering assumptions (e.g., an event logger running before aTaskCompletionSource-based waiter) depend on the collection's enumeration order.DispatchEventreads the field once and invokes it, so handlers added/removed during dispatch don't affect the current iteration. No.ToArray()allocation needed.DisposeAsync:.Clear()replaced with= null