Skip to content

[Experiment] Reduces use of locks and improves thread-safe logic - #542

Closed
Julio Carlos Menendez (juliomenendez) wants to merge 6 commits into
mainfrom
juliome/streaming-without-lock
Closed

[Experiment] Reduces use of locks and improves thread-safe logic#542
Julio Carlos Menendez (juliomenendez) wants to merge 6 commits into
mainfrom
juliome/streaming-without-lock

Conversation

@juliomenendez

@juliomenendez Julio Carlos Menendez (juliomenendez) commented Dec 18, 2025

Copy link
Copy Markdown

Removes direct use of lock and timers in StreamingReponse for an approach using reactive programming.

@github-actions github-actions Bot added ML: Core Tags changes to core libraries ML: Packages labels Dec 18, 2025
@github-actions github-actions Bot added the ML: Tests Tags changes to tests label Dec 23, 2025

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR refactors the StreamingResponse class to use reactive programming patterns instead of explicit locks and timers. The goal is to reduce lock contention and improve thread-safe logic by introducing a new DynamicIntervalTextChunkBatcher component based on System.Reactive.

Key Changes:

  • Replaces timer-based batching with a reactive observable pipeline using DynamicIntervalTextChunkBatcher
  • Removes explicit lock statements and synchronization primitives (Timer, AutoResetEvent, queue)
  • Introduces reactive subjects for managing activity emissions and text batching

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 23 comments.

Show a summary per file
File Description
DynamicIntervalTextChunkBatcher.cs New reactive component that batches text chunks using Rx observables with configurable intervals
DynamicIntervalTextChunkBatcherTests.cs Comprehensive test suite for the new batcher component
StreamingResponse.cs Major refactoring to use reactive pipeline instead of locks and timers
Microsoft.Agents.Builder.csproj Adds System.Reactive package reference
Directory.Packages.props Adds System.Reactive version specification
Comments suppressed due to low confidence (1)

src/libraries/Builder/Microsoft.Agents.Builder/StreamingResponse.cs:415

                    if (_canceled)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +81 to +100
public async Task TestOnCompletedFlushesRemainingText()
{
var batcher = new DynamicIntervalTextChunkBatcher(5000); // Long interval
string text = string.Empty;
var subscription = batcher.Subscribe(value => text = value);

batcher.OnNext("quick");
batcher.OnNext(" flush");

// Complete immediately without waiting for interval
batcher.OnCompleted();

// Give a small delay for the completion to process
await Task.Delay(50);

Assert.Equal("quick flush", text);

subscription.Dispose();
batcher.Dispose();
}

Copilot AI Dec 23, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test name "TestOnCompletedFlushesRemainingText" implies that OnCompleted should flush remaining text, but given the current implementation's flaw (issue #8), this test might not actually verify the correct behavior. The 50ms delay is a race condition - if the interval hasn't fired, the text won't be flushed. Consider testing this behavior more reliably or updating the test name if the current behavior is intentional.

Copilot uses AI. Check for mistakes.
Func<IActivity> queueFunc;

lock (this)
if (_ended)

Copilot AI Dec 23, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Race condition: The check of _ended and subsequent operations are not atomic. Without synchronization, another thread could set _ended to true after the check but before setting _streamStarted, leading to inconsistent state. The old implementation protected these operations with a lock. Consider using Volatile.Read for the check or adding appropriate synchronization.

Suggested change
if (_ended)
if (Volatile.Read(ref _ended))

Copilot uses AI. Check for mistakes.
Comment on lines +341 to +343
_streamStarted = true;
_accumulatedMessage += text;
Message = CitationUtils.FormatCitationsResponse(_accumulatedMessage);

Copilot AI Dec 23, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Race condition: Multiple threads could simultaneously update _accumulatedMessage and Message, causing data loss or corruption. The string concatenation operations are not atomic. The old implementation protected these operations with a lock. Consider using a thread-safe approach or ensuring only one thread writes to these fields.

Copilot uses AI. Check for mistakes.
Comment on lines +420 to 421
catch
{

Copilot AI Dec 23, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The catch block catches all exceptions without logging or rethrowing, which could hide bugs and make debugging difficult. The old implementation caught specific exception types and had detailed error handling. Consider at minimum catching specific exceptions or adding logging before swallowing exceptions.

Suggested change
catch
{
catch (Exception ex)
{
_logger?.LogError(ex, "Error while completing streaming response.");

Copilot uses AI. Check for mistakes.
Comment on lines +164 to +176
public void TestOnNextAfterCompleted()
{
var batcher = new DynamicIntervalTextChunkBatcher(200);
var emissions = new List<string>();
var subscription = batcher.Subscribe(emissions.Add);

batcher.OnNext("before");
batcher.OnCompleted();

// This should be ignored
batcher.OnNext("after");

Assert.DoesNotContain("after", string.Join("", emissions));

Copilot AI Dec 23, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Race condition in test: After calling OnCompleted, the test immediately checks if "before" is in emissions without waiting for the completion to propagate. Due to timing, the emissions list might still be empty. Consider adding a small delay or using a more reliable synchronization mechanism to ensure the completion has been processed before asserting.

Copilot uses AI. Check for mistakes.
Comment on lines +161 to +164
.Subscribe(
async activity =>
{
await SendActivityAsync(activity, CancellationToken.None).ConfigureAwait(false);

Copilot AI Dec 23, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The async lambda in the Subscribe method can lead to unobserved exceptions and race conditions. Reactive Extensions' Subscribe doesn't handle async delegates correctly - it returns immediately without awaiting the task, which can cause exceptions to be swallowed and operations to complete out of order. Consider using Observable.SelectMany or a synchronous subscription that properly handles the async work.

Suggested change
.Subscribe(
async activity =>
{
await SendActivityAsync(activity, CancellationToken.None).ConfigureAwait(false);
.SelectMany(activity => Observable.FromAsync(() => SendActivityAsync(activity, CancellationToken.None)))
.Subscribe(
_ =>
{
// No-op: all work is done in SendActivityAsync; this handler exists to observe completion.

Copilot uses AI. Check for mistakes.
Comment on lines +34 to +35
buffer.Append(chunk);
return buffer;

Copilot AI Dec 23, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Scan operator accumulates text in a single StringBuilder instance that is mutated and reused across emissions. This can cause race conditions where the same StringBuilder reference is emitted multiple times, leading to downstream observers seeing the same (changing) buffer. Each emission of ToString() will reflect the current state of the buffer, not the state at the time of emission. Consider creating a new string or StringBuilder for each emission.

Suggested change
buffer.Append(chunk);
return buffer;
var newBuffer = new StringBuilder(buffer.ToString());
newBuffer.Append(chunk);
return newBuffer;

Copilot uses AI. Check for mistakes.
Comment on lines +377 to +382
if (_ended)
{
if (_ended)
{
return StreamingResponseResult.AlreadyEnded;
}
return StreamingResponseResult.AlreadyEnded;
}

_ended = true;
_ended = true;

Copilot AI Dec 23, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Race condition: Multiple threads could read and write _streamStarted, _ended, and other boolean fields without synchronization. While boolean reads/writes are atomic on most platforms, memory visibility is not guaranteed across threads without proper synchronization. Consider using Volatile.Read/Write or Interlocked operations for all shared boolean fields to ensure thread-safe access.

Copilot uses AI. Check for mistakes.
/// <exception cref="System.InvalidOperationException">Throws if the stream has already ended.</exception>
public async Task QueueInformativeUpdateAsync(string text, CancellationToken cancellationToken = default)
public Task QueueInformativeUpdateAsync(string text, CancellationToken cancellationToken = default)
{

Copilot AI Dec 23, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cancellationToken parameter is ignored. The old implementation used CancellationToken.None explicitly, but the new implementation doesn't use the provided token at all. If the intention is to not support cancellation, this should be documented, otherwise the provided cancellationToken should be used or passed through to async operations.

Suggested change
{
{
if (cancellationToken.IsCancellationRequested)
{
return Task.FromCanceled(cancellationToken);
}

Copilot uses AI. Check for mistakes.
Comment on lines +112 to +116
_disposed = true;
_subscription?.Dispose();
_inputSubject?.Dispose();
_intervalSubject?.Dispose();
_outputSubject?.Dispose();

Copilot AI Dec 23, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Setting _disposed = true before disposing resources creates a time window where the object appears disposed but resources are still active. If another thread checks _disposed during disposal, it might assume cleanup is complete when it's not. Consider setting the flag after all resources are disposed, or use a lock to make the entire disposal atomic.

Suggested change
_disposed = true;
_subscription?.Dispose();
_inputSubject?.Dispose();
_intervalSubject?.Dispose();
_outputSubject?.Dispose();
_subscription?.Dispose();
_inputSubject?.Dispose();
_intervalSubject?.Dispose();
_outputSubject?.Dispose();
_disposed = true;

Copilot uses AI. Check for mistakes.
@tracyboehrer

Copy link
Copy Markdown
Member

Julio Carlos Menendez (@juliomenendez) I see this and will review soon. Some of the Copilot suggestions seem relevant.

@juliomenendez

Copy link
Copy Markdown
Author

Still have work on-going for this, trying to align more to observables architecture. Let me mark it as draft until I get time to wrap it up. Thanks tracyboehrer (@tracyboehrer) !

@juliomenendez

Copy link
Copy Markdown
Author

Superseeded by #655

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ML: Core Tags changes to core libraries ML: Packages ML: Tests Tags changes to tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants