Serialize event dispatch in .NET and Go SDKs#791
Conversation
There was a problem hiding this comment.
Pull request overview
This PR aims to enforce serialized (in-order, non-concurrent) dispatch of session events and broadcast work (tool/permission handling) in the Go and .NET SDKs to prevent out-of-order delivery, concurrent handler execution, and untracked broadcast work.
Changes:
- Go: run broadcast tool/permission handling inline (remove untracked goroutines) and update Disconnect docs.
- .NET: enqueue incoming events into a single-reader channel and process them in a dedicated consumer loop that awaits broadcast handling before invoking user handlers.
- Add/adjust tests to validate serial dispatch, handler exception resilience, and DisposeAsync-from-handler behavior.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| go/session.go | Removes goroutine spawning for broadcast work to attempt fully serialized handling. |
| go/session_test.go | Adds tests intended to validate serial dispatch and panic resilience. |
| dotnet/src/Session.cs | Introduces Channel-based serialized event dispatch loop and logging for exceptions/dropped events. |
| dotnet/src/Client.cs | Threads ILogger into CopilotSession construction. |
| dotnet/test/SessionTests.cs | Adds/updates tests for serial dispatch, exception resilience, and dispose deadlock avoidance. |
Comments suppressed due to low confidence (1)
dotnet/test/SessionTests.cs:516
TaskCompletionSourceis generic in the target frameworks;new TaskCompletionSource()will not compile. Use a typedTaskCompletionSource(e.g.,TaskCompletionSource<bool>orTaskCompletionSource<object?>) and set an explicit result when completing it.
var session = await CreateSessionAsync();
var disposed = new TaskCompletionSource();
session.On(evt =>
{
if (evt is UserMessageEvent)
{
// Call DisposeAsync from within a handler — must not deadlock.
session.DisposeAsync().AsTask().ContinueWith(_ => disposed.TrySetResult());
}
});
You can also share your feedback on Copilot code review. Take the survey.
Cross-SDK Consistency ReviewThis PR improves event dispatch serialization in .NET and Go SDKs, which is excellent for correctness. However, it introduces a behavioral inconsistency with Node.js and Python SDKs regarding broadcast event handling. Current State After This PR
The InconsistencyIn .NET/Go (after this PR): // dotnet/src/Session.cs:315-319
await HandleBroadcastEventAsync(sessionEvent); // ⬅️ Awaited inline
// ...
EventHandlers?.Invoke(sessionEvent); // User handlers run AFTER tool completesIn Node.js: // nodejs/src/session.ts:338
void this._executeToolAndRespond(...); // ⬅️ Fire-and-forget
// Falls through immediately to user handler dispatchIn Python: # python/copilot/session.py:292-296
asyncio.ensure_future(
self._execute_tool_and_respond(...) # ⬅️ Fire-and-forget
)
# Falls through immediately to user handler dispatchWhy This Matters
RecommendationOption A (Preferred): Update Node.js and Python to match .NET/Go behavior:
Option B: Document the difference as intentional:
Option C: Keep as-is if this was a deliberate choice:
Related Code Locations
|
There was a problem hiding this comment.
Generated by SDK Consistency Review Agent for issue #791
SDK Consistency Review: Serial Event DispatchI've reviewed this PR for cross-SDK consistency. The changes correctly address a concurrency issue specific to Go and .NET that doesn't affect Node.js and Python. ✅ Design Consistency ConfirmedThe PR correctly identifies and addresses architectural differences: Go & .NET (multi-threaded runtimes):
Node.js & Python (single-threaded event loops):
I verified both Node.js and Python dispatch events synchronously on their event loops:
✅ API Surface ConsistencyPublic API is unchanged across all SDKs:
Implementation details appropriately diverge based on runtime characteristics:
✅ Documentation ConsistencyThe PR adds consistent caller-ensures-idle contracts to both affected SDKs: .NET
Go
Recommendation: Consider adding similar guidance to Node.js/Python, even though deadlock isn't possible there. It would establish a consistent best practice across all SDKs. ✅ Test Coverage ConsistencyNew tests are appropriate and SDK-specific:
Node.js and Python don't need equivalent tests because concurrency isn't possible on single-threaded event loops. SummaryNo consistency issues found. This PR correctly implements platform-appropriate solutions to a concurrency problem that only affects multi-threaded SDKs. The behavioral guarantees (serial dispatch, handler exceptions don't halt delivery) are now consistent across all four SDKs, even though the implementation mechanisms differ based on runtime architecture. The PR is well-designed and maintains the project's cross-language consistency goals. ✅
|
Cross-SDK Consistency Review ✅I've reviewed PR #791 for consistency across all four SDK implementations. Overall: This PR successfully brings .NET and Go into alignment with the existing behavior in Node.js and Python. Summary of ChangesThis PR addresses a runtime-specific concurrency issue in .NET and Go:
The implementations now provide consistent event ordering guarantees across all languages. ✅ What's Consistent
📝 Documentation Opportunity (Optional Enhancement)The .NET SDK now explicitly documents the serial dispatch guarantee:
Suggestion: Consider adding similar documentation to the Node.js and Python SDKs'
Similarly, the "caller-ensures-idle" contract added to .NET This is purely a documentation enhancement and not a blocker—the actual behavior is already correct and consistent. Verdict: ✅ This PR maintains excellent cross-SDK consistency. The changes are appropriate and well-tested. Nice work!
|
Cross-SDK Consistency Review ✅I've reviewed this PR for consistency across all four SDK implementations (Node.js/TypeScript, Python, Go, and .NET). SummaryThis PR introduces serial event dispatch to the .NET and Go SDKs to fix concurrency issues. The changes are consistent with the existing architecture of Node.js and Python, which are inherently single-threaded and don't experience these problems. What ChangedGo SDK:
.NET SDK:
Cross-SDK Consistency Analysis✅ Node.js & Python — No changes needed:
✅ Go & .NET now match Node.js/Python behavior:
Design Decisions Confirmed Across All SDKs
RecommendationNo cross-SDK changes needed. This PR appropriately brings .NET and Go into alignment with the existing event delivery semantics of Node.js and Python. The implementation approaches differ due to language idioms (.NET uses Channels, Go uses goroutines, Node.js/Python use event loops), but the public API guarantees are now consistent across all four SDKs. The PR description explicitly acknowledges this: "Node.js and Python are single-threaded (event loop / asyncio) and do not have these issues."
|
Cross-SDK Consistency Review ✅I've reviewed this PR for consistency across all four SDK implementations. No consistency issues found — the changes appropriately address concurrency concerns that only exist in .NET and Go. SummaryThis PR serializes event dispatch in the .NET and Go SDKs to prevent concurrent handler execution. The changes are well-justified and SDK-appropriate: ✅ .NET Changes: Uses a ✅ Go Changes: Broadcast work ( ✅ Node.js & Python: Correctly not modified — both are single-threaded (event loop / asyncio) and already guarantee serial event dispatch by design. Making similar changes would be unnecessary and potentially harmful to their performance characteristics. Why This is ConsistentThe four SDKs handle concurrency differently based on their language/runtime:
The PR correctly recognizes that behavioral consistency (serial event dispatch) requires different implementation approaches in different languages. Node.js and Python get serial dispatch "for free" from their single-threaded runtimes, while Go and .NET need explicit synchronization mechanisms. Testing ConsistencyAll SDKs that needed changes include appropriate tests:
Documentation ConsistencyThe PR adds "caller-ensures-idle" documentation to both Conclusion: This PR maintains cross-SDK behavioral consistency (serial event dispatch with resilient error handling) while respecting each language's concurrency model. The changes are appropriate and well-tested. ✨
|
Pull request was converted to draft
Cross-SDK Consistency Review: Event SerializationI've reviewed this PR for consistency across all four SDK implementations. Here are my findings: ✅ Correctly Addresses: .NET and GoThe PR properly fixes concurrency issues in .NET and Go where event handlers could run:
|
SDK Consistency Review: Serial Event DispatchI've reviewed PR #791 for cross-SDK consistency. Here are my findings: ✅ Changes are Appropriately ScopedThis PR correctly targets only .NET and Go SDKs to fix concurrency issues that are specific to multi-threaded runtimes. The PR description explicitly notes:
Analysis by SDK.NET Changes ✅
Go Changes ✅
Node.js & Python: Unchanged ✅Both SDKs are inherently serial due to single-threaded event loop semantics:
Their Semantic Consistency Across All SDKsAfter these changes, all four SDKs now guarantee:
Documentation ConsistencyThe PR adds caller-ensures-idle contract documentation to .NET (
This would ensure users of all SDKs understand the same contract about session lifecycle. RecommendationApprove — The changes maintain appropriate cross-SDK consistency while respecting the idioms of each runtime. The serial dispatch guarantee is now explicit in all four implementations, achieved through different mechanisms appropriate to each platform:
The only minor suggestion is to sync the "caller-ensures-idle" documentation across all SDKs for complete consistency.
|
1657f9c to
7ac0b8a
Compare
7ac0b8a to
819a771
Compare
Cross-SDK Consistency Review: Event Dispatch SerializationI've reviewed PR #791 for cross-SDK consistency. This PR fixes concurrent event handler execution issues in .NET and Go, but there's an important inconsistency with the Python SDK that should be addressed. ✅ What This PR Fixes.NET Changes:
Go Changes:
|
| SDK | User Event Handlers | Broadcast Handlers (tools/permissions) | Serial Guarantee |
|---|---|---|---|
| Node.js | ✅ Serial (sync loop) | ✅ Serial (fire-and-forget async, but event loop serializes) | ✅ Yes |
| .NET (after PR) | ✅ Serial (Channel consumer) | ✅ Serial (awaited inline by consumer) | ✅ Yes |
| Go (after PR) | ✅ Serial (event channel + processEvents) | ✅ Serial (inline execution) | ✅ Yes |
| Python (current) | ✅ Serial (sync loop) | ❌ Concurrent (asyncio.ensure_future) |
❌ No |
💡 Recommendation
To maintain feature parity and consistent behavior across all SDKs, the Python SDK should adopt the same serialization approach:
Option 1: Inline await (like .NET post-PR)
async def _handle_broadcast_event(self, event: SessionEvent) -> None:
if event.type == SessionEventType.EXTERNAL_TOOL_REQUESTED:
# ... validation ...
await self._execute_tool_and_respond(...) # Await inline instead of ensure_futureOption 2: Event queue with single consumer (like Go post-PR)
- Use
asyncio.Queueto serialize all handler dispatch - Single consumer task processes events in FIFO order
- Guarantees serial execution and ordering
Suggested approach: Option 1 is simpler and matches .NET's design. The Python SDK should:
- Change
_handle_broadcast_eventtoasync - Replace
asyncio.ensure_future()withawait - Make
_dispatch_eventasync and await_handle_broadcast_event - Add tests similar to .NET/Go for serial dispatch validation
🎯 Summary
- This PR correctly fixes .NET and Go ✅
- Node.js doesn't need changes (inherently serial) ✅
- Python needs the same fix to maintain consistency
⚠️
The serialization guarantee is an important semantic property that users will rely on - having different behavior in Python creates a cross-platform consistency issue and potential bugs when code is ported between SDKs.
Would you like me to create a follow-up issue to track the Python serialization work, or should it be addressed in this PR?
Generated by SDK Consistency Review Agent for issue #791 · ◷
Re: Cross-SDK Consistency Review
The consistency review above analyzed a stale version of the PR. The current implementation fires broadcast handlers as fire-and-forget in all four SDKs, which is the correct design. The review's recommendation to \�wait\ broadcast handlers in Python would reintroduce the deadlock/blocking problems this PR fixes. Actual state after this PR
All four SDKs are consistent. Broadcast handlers must run concurrently/fire-and-forget because:
The review's table incorrectly shows .NET and Go as having serial/inline broadcast handlers. That was an earlier iteration that was reverted for exactly these reasons. |
819a771 to
4d1d57c
Compare
✅ Cross-SDK Consistency Review: APPROVEDThis PR successfully brings .NET and Go event dispatch in line with Node.js and Python. No consistency issues found. Core AchievementAll four SDKs now guarantee:
Implementation Comparison
Language-Appropriate AdaptationsThe following differences are idiomatic and appropriate for each language:
Test CoverageBoth .NET and Go added comprehensive tests:
Public API ImpactNone. All changes are internal implementation details. The public API surface remains consistent across all four SDKs. Recommendation: Approve and merge. This PR successfully maintains cross-SDK consistency while implementing critical fixes for .NET and Go concurrency issues.
|
4d1d57c to
74f0a0d
Compare
In .NET, StreamJsonRpc dispatches notifications concurrently on the thread pool. The old code invoked user event handlers inline from DispatchEvent, which meant handlers could run concurrently and out of order. In Go, the JSON-RPC read loop is single-threaded, so user handlers were already serialized. However, broadcast handlers (tool calls, permission requests) ran inline on the read loop, which deadlocked when a handler issued an RPC request back through the same connection. This PR decouples user handler dispatch from the transport by routing events through a channel (Go) / Channel<T> (.NET). A single consumer goroutine/task drains the channel and invokes user handlers serially, in FIFO order. This matches the guarantees provided by the Node.js and Python SDKs (which get natural serialization from their single-threaded event loops) while fitting Go's and .NET's multi-threaded runtimes. Broadcast handlers (tool calls, permission requests) are fired as fire-and-forget directly from the dispatch entry point, outside the channel, so a stalled handler cannot block event delivery. This matches the existing Node.js (void this._executeToolAndRespond()) and Python (asyncio.ensure_future()) behavior. Go changes: - Add eventCh channel to Session; start processEvents consumer goroutine - dispatchEvent enqueues to channel and fires broadcast handler goroutine - Close channel on Disconnect to stop the consumer - Update unit tests and E2E tests for async delivery .NET changes: - Add unbounded Channel<SessionEvent> to CopilotSession; start ProcessEventsAsync consumer task in constructor - DispatchEvent enqueues to channel and fires broadcast handler task - Complete channel on DisposeAsync - Per-handler error catching via ImmutableArray iteration - Cache handler array snapshot to avoid repeated allocation - Inline broadcast error handling into HandleBroadcastEventAsync - Update Should_Receive_Session_Events test to await async delivery - Add Handler_Exception_Does_Not_Halt_Event_Delivery test - Add DisposeAsync_From_Handler_Does_Not_Deadlock test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
74f0a0d to
e320690
Compare
Summary
Decouple user event handler dispatch from the transport in both .NET and Go SDKs. Events are routed through a channel, consumed by a single background goroutine/task that invokes user handlers serially in FIFO order. Broadcast handlers (tool calls, permission requests) are fired as fire-and-forget directly from the dispatch entry point, outside the channel.
This brings .NET and Go in line with the Node.js and Python SDKs, which get natural serialization from their single-threaded event loops.
Problem
In .NET, StreamJsonRpc dispatches notifications concurrently on the ThreadPool, which meant:
In Go, the JSON-RPC read loop was single-threaded so user handlers were already serialized, but broadcast handlers (tool calls, permission requests) ran inline, which meant:
Design
Both SDKs now use the same pattern, adapted to their runtime:
voidfire-and-forgetasyncio.ensure_futuregogoroutine_ =fire-and-forget taskKey properties:
.NET Changes
Channel<SessionEvent>with single-reader consumer task (ProcessEventsAsync) that invokes user handlers seriallyDispatchEventenqueues to channel and fires broadcast handler as_ = HandleBroadcastEventAsync(...)(fire-and-forget)ImmutableArray<SessionEventHandler>iteration with cached snapshotHandleBroadcastEventAsyncwraps broadcast work in try/catch directly (no separate Safe wrapper)DisposeAsync; consumer task exits naturallyILoggerthreaded fromCopilotClienttoCopilotSessionfor diagnosticsGo Changes
eventChchannel added to Session;processEventsconsumer goroutine started innewSessiondispatchEventenqueues to channel and firesgo s.handleBroadcastEvent(event)Disconnectto stop the consumerTests
Should_Receive_Session_Events(modified)Handler_Exception_Does_Not_Halt_Event_Delivery(new)DisposeAsync_From_Handler_Does_Not_Deadlock(new)events are dispatched serially(new)handler panic does not halt delivery(new)should_receive_session_events(modified)