[Experiment] Reduces use of locks and improves thread-safe logic - #542
[Experiment] Reduces use of locks and improves thread-safe logic#542Julio Carlos Menendez (juliomenendez) wants to merge 6 commits into
Conversation
…otential deadlock code.
4ca84e0 to
036c0e2
Compare
There was a problem hiding this comment.
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
lockstatements 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
- Condition is always false because of access to field _canceled.
if (_canceled)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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(); | ||
| } |
There was a problem hiding this comment.
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.
| Func<IActivity> queueFunc; | ||
|
|
||
| lock (this) | ||
| if (_ended) |
There was a problem hiding this comment.
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.
| if (_ended) | |
| if (Volatile.Read(ref _ended)) |
| _streamStarted = true; | ||
| _accumulatedMessage += text; | ||
| Message = CitationUtils.FormatCitationsResponse(_accumulatedMessage); |
There was a problem hiding this comment.
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.
| catch | ||
| { |
There was a problem hiding this comment.
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.
| catch | |
| { | |
| catch (Exception ex) | |
| { | |
| _logger?.LogError(ex, "Error while completing streaming response."); |
| 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)); |
There was a problem hiding this comment.
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.
| .Subscribe( | ||
| async activity => | ||
| { | ||
| await SendActivityAsync(activity, CancellationToken.None).ConfigureAwait(false); |
There was a problem hiding this comment.
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.
| .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. |
| buffer.Append(chunk); | ||
| return buffer; |
There was a problem hiding this comment.
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.
| buffer.Append(chunk); | |
| return buffer; | |
| var newBuffer = new StringBuilder(buffer.ToString()); | |
| newBuffer.Append(chunk); | |
| return newBuffer; |
| if (_ended) | ||
| { | ||
| if (_ended) | ||
| { | ||
| return StreamingResponseResult.AlreadyEnded; | ||
| } | ||
| return StreamingResponseResult.AlreadyEnded; | ||
| } | ||
|
|
||
| _ended = true; | ||
| _ended = true; |
There was a problem hiding this comment.
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.
| /// <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) | ||
| { |
There was a problem hiding this comment.
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.
| { | |
| { | |
| if (cancellationToken.IsCancellationRequested) | |
| { | |
| return Task.FromCanceled(cancellationToken); | |
| } |
| _disposed = true; | ||
| _subscription?.Dispose(); | ||
| _inputSubject?.Dispose(); | ||
| _intervalSubject?.Dispose(); | ||
| _outputSubject?.Dispose(); |
There was a problem hiding this comment.
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.
| _disposed = true; | |
| _subscription?.Dispose(); | |
| _inputSubject?.Dispose(); | |
| _intervalSubject?.Dispose(); | |
| _outputSubject?.Dispose(); | |
| _subscription?.Dispose(); | |
| _inputSubject?.Dispose(); | |
| _intervalSubject?.Dispose(); | |
| _outputSubject?.Dispose(); | |
| _disposed = true; |
|
Julio Carlos Menendez (@juliomenendez) I see this and will review soon. Some of the Copilot suggestions seem relevant. |
|
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) ! |
|
Superseeded by #655 |
Removes direct use of
lockand timers inStreamingReponsefor an approach using reactive programming.