Skip to content

[Bug]: HttpStream Close() race condition + AddStreamFinal channelData.StreamType override #473

Description

@corinagum

Bug Description

Two bugs in the C# streaming path. Both are the parity of microsoft/teams.ts#549, fixed in microsoft/teams.ts#553, but the C# version of Bug 1 manifests via a different mechanism (??=) than the TS version.


Bug 1: AddStreamFinal() cannot override an existing ChannelData.StreamType

Location: Libraries/Microsoft.Teams.Api/Activities/Message/MessageActivity.cs:208-221

public MessageActivity AddStreamFinal()
{
    ChannelData ??= new();
    ChannelData.StreamId ??= Id;
    ChannelData.StreamType ??= StreamType.Final;   // ← only sets if null

    AddEntity(new StreamInfoEntity()
    {
        StreamId = Id,
        StreamType = StreamType.Final
    });

    return this;
}

The method's contract — judged by name — is to mark this activity as a final stream message. But ??= makes it impossible to override an existing StreamType. In the streaming path:

  1. Stream.Update("Thinking...") (Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.Stream.cs:75) emits a typing activity with ChannelData.StreamType = Informative.
  2. Flush() accumulates that into _channelData via _channelData.Merge(...) (line 152).
  3. Close() builds the final activity with WithData(_channelData) (line 104), populating activity.ChannelData.StreamType = Informative.
  4. AddStreamFinal() (line 106) runs ??= against StreamType.Informative (non-null) — no-op. The final wire activity has channelData.streamType = "informative" even though the streaminfo entity correctly has streamType = "final".

This is the parity of TS Bug 1 — Teams receives an inconsistent final stream state.

Fix

Change ??= to direct assignment in AddStreamFinal():

ChannelData.StreamId = Id;
ChannelData.StreamType = StreamType.Final;

This is a minor public-API behavior change (callers who pre-set StreamType would now have it overridden by AddStreamFinal), but it aligns the method with its name and contract.


Bug 2: Race condition in Stream.Close()

Location: Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.AspNetCore/AspNetCorePlugin.Stream.cs:90-93

while (_id is null || _queue.Count > 0)
{
    await Task.Delay(50, cancellationToken);
}

This wait checks _id and _queue but not whether Flush() is currently holding _lock (a SemaphoreSlim). Flush() acquires the lock at line 127 and drains the queue before await SendActivity(...) (lines 173, 181). During those awaits:

  • _queue.Count == 0 (drained)
  • _id is set (assigned on line 202 after first send)
  • _lock is still held

Close() exits the wait early and sends the final activity, racing the in-flight chunk.

Fix

Add _lock.CurrentCount == 0 to the loop condition:

while (_id is null || _queue.Count > 0 || _lock.CurrentCount == 0)

The _lock.CurrentCount primitive is already used in this file (line 88, the early-return check).


I have fixes + unit tests for both bugs ready to submit as a PR.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions