Fix HttpStream Close() race + AddStreamFinal() streamType override#474
Conversation
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
This PR fixes two streaming-path issues: ensuring final stream activities always carry StreamType.Final, and making Stream.Close() wait for an in-flight flush before sending the final activity.
Changes:
- Update
MessageActivity.AddStreamFinal()to assign final stream metadata directly. - Update
AspNetCorePlugin.Stream.Close()to wait while a flush still holds the semaphore. - Add tests covering final-message stream type behavior and the
Close()/Flush()race.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| Tests/Microsoft.Teams.Plugins.AspNetCore.Tests/AspNetCorePluginStreamTests.cs | Adds integration-style tests for final stream metadata and the in-flight flush race. |
| Tests/Microsoft.Teams.Api.Tests/Activities/Message/MessageActivityTests.cs | Adds a unit test for AddStreamFinal() overriding an existing stream type. |
| Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.Stream.cs | Changes Close() to also wait while the flush semaphore is held. |
| Libraries/Microsoft.Teams.Api/Activities/Message/MessageActivity.cs | Changes AddStreamFinal() from null-coalescing assignment to direct assignment. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- AddStreamFinal: keep StreamId as ??= (don't clobber an existing value) - Stream tests: replace Task.Delay-based waits with TaskCompletionSource signals from the Send delegate; simplify Bug 1 integration test to rely on Close()'s wait loop instead of fixed sleeps
- AddStreamFinal: keep StreamId as ??= (don't clobber an existing value) - Stream tests: replace Task.Delay-based waits with TaskCompletionSource signals from the Send delegate; simplify Bug 1 integration test to rely on Close()'s wait loop instead of fixed sleeps
75b0c96 to
5ceed67
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- AddStreamFinal: keep StreamId as ??= (don't clobber an existing value) - Stream tests: replace Task.Delay-based waits with TaskCompletionSource signals from the Send delegate; simplify Bug 1 integration test to rely on Close()'s wait loop instead of fixed sleeps
5ceed67 to
336a647
Compare
- AddStreamFinal: keep StreamId as ??= (don't clobber an existing value) - Stream tests: replace Task.Delay-based waits with TaskCompletionSource signals from the Send delegate; simplify Bug 1 integration test to rely on Close()'s wait loop instead of fixed sleeps
336a647 to
94cdba9
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
Libraries/Microsoft.Teams.Api/Activities/Message/MessageActivity.cs:217
AddStreamFinal()setsStreamInfoEntity.StreamIdfromId, but leavesChannelData.StreamIdas??= Id. If a caller pre-setsChannelData.StreamId(orIdis null), this can produce a mismatch betweenchannelData.streamIdand thestreaminfoentity’sstreamId. Consider makingChannelData.StreamIdassignment consistent with the entity (or vice versa) so the final activity is internally consistent.
ChannelData ??= new();
ChannelData.StreamId ??= Id;
ChannelData.StreamType = StreamType.Final;
AddEntity(new StreamInfoEntity()
{
StreamId = Id,
StreamType = StreamType.Final
lilyydu
left a comment
There was a problem hiding this comment.
LGTM though copilot's comments seem worthy to evaluate
…de (#473) Two bugs in the streaming path: 1. AddStreamFinal() used `??=` for ChannelData.StreamId / StreamType, so it could not override an existing StreamType. The streaming Close() path accumulates `_channelData.StreamType = Informative` from prior typing updates and merges that into the final activity via WithData() before calling AddStreamFinal() — which then no-op'd, leaving the wire `channelData.streamType` as "informative" on the final message even though the streaminfo entity correctly said "final". Fix: direct assignment in AddStreamFinal(). The method's contract per its name is to mark the activity as a final stream message, so it must be able to override prior values. 2. Stream.Close() polled `_id` and `_queue` but not `_lock` — during a flush mid-await (queue drained, SendActivity awaits pending), Close() would proceed and send the final message before the in-flight chunk completed. Fix: also wait while `_lock.CurrentCount == 0`. This is the same primitive already used in Close()'s early-return guard.
- AddStreamFinal: keep StreamId as ??= (don't clobber an existing value) - Stream tests: replace Task.Delay-based waits with TaskCompletionSource signals from the Send delegate; simplify Bug 1 integration test to rely on Close()'s wait loop instead of fixed sleeps
3ce81e0 to
acb6187
Compare
Fixes #473
Summary
Two bugs in the streaming path. Parity of microsoft/teams.ts#553, but Bug 1 manifests via a different mechanism in C# (
??=) than in TS.Bug 1:
AddStreamFinal()cannot override existingChannelData.StreamTypeMessageActivity.AddStreamFinal()used null-coalescing assignment:In the streaming
Close()path:Stream.Update("Thinking...")accumulatesStreamType.Informativeinto_channelData.Close()callsWithData(_channelData), populatingactivity.ChannelData.StreamType = Informative.AddStreamFinal()runs??=againstInformative→ no-op. The wirechannelData.streamTypestays as"informative"on the final message, even though thestreaminfoentity correctly has"final".Fix: direct assignment. The method's contract per its name is to mark the activity as a final stream message, so it must be able to override prior values.
Bug 2: Race condition in
Stream.Close()Close()'s wait loop checked_idand_queuebut not whetherFlush()was holding_lock. During a flush mid-await (queue drained,SendActivity()awaits pending),Close()would exit the loop and send the final activity, racing the in-flight chunk.Fix: also wait while
_lock.CurrentCount == 0(semaphore held). This is the same primitive already used inClose()'s early-return guard on line 88.Changes
Libraries/Microsoft.Teams.Api/Activities/Message/MessageActivity.cs—AddStreamFinal()direct assignment.Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.Stream.cs—Close()lock-aware wait.MessageActivityTests.csandAspNetCorePluginStreamTests.cs.Test plan
AddStreamFinal_OverridesExistingStreamType—MessageActivitywithStreamType.Informativepre-set,AddStreamFinal()overrides toFinal.Stream_Close_FinalMessageHasStreamTypeFinal_AfterInformativeUpdate— full Update→Emit→Close flow asserts final activity hasStreamType.Finalon bothChannelDataand thestreaminfoentity.Stream_Close_WaitsForInFlightFlushToComplete— uses a controllableSenddelegate to block the second flush mid-await; assertsClose()does not progress until the lock releases.MessageActivityTests(21 passed) andAspNetCorePluginStreamTests(6 passed) still pass on net10.0. (net8.0 testhost not installed locally; CI will cover.)