fix: resolve console output glitches with progress display#2193
fix: resolve console output glitches with progress display#2193
Conversation
- Add render lock synchronization to prevent progress refresh during output writes - Use atomic flag tracking to safely coordinate lock ownership between pause/resume - Add flushing output flag to bypass buffering when writing module output - Fix ResumeAsync to always release render lock even after timeout - Simplify file operation log messages by removing Module/Activity info Fixes race conditions where: - Progress ctx.Refresh() could interleave with module output - Spectre.Console markup appeared raw ([bold cyan]?[/]) instead of rendered - Timeout in progress loop could leave render lock unreleased
SummaryFixes console output glitches by adding render lock synchronization between progress updates and module output, plus a bypass flag to prevent buffering during output flushes. Critical Issues1. Timeout path does not release render lock or signal pause completion In ProgressSession.cs:130-148, when the 60-second timeout occurs, the code clears
The timeout handling code must be updated to match the cleanup logic in if (completedTask == timeoutTask)
{
// Timeout - force resume to prevent stuck state
_logger.LogWarning(/* ... */);
await _pauseLock.WaitAsync().ConfigureAwait(false);
try
{
_isPaused = false;
_resumeSignal?.TrySetResult();
_resumeSignal = null;
_pauseComplete?.TrySetResult(); // ADD THIS
}
finally
{
_pauseLock.Release();
}
// ADD THIS - Release render lock if we held it
if (Interlocked.Exchange(ref _renderLockHeldByPause, 0) == 1)
{
_renderLock.Release();
}
}2. Race condition in DisposeAsync In ProgressSession.cs:458-465, The semaphore disposal should happen inside a lock or after ensuring no concurrent operations are possible: public async ValueTask DisposeAsync()
{
// First ensure we're not paused
await _pauseLock.WaitAsync().ConfigureAwait(false);
try
{
// Atomically check and clear - only release if we held the lock
if (Interlocked.Exchange(ref _renderLockHeldByPause, 0) == 1)
{
_renderLock.Release();
}
}
finally
{
_pauseLock.Release();
}
// ... rest of disposal logic ...
// NOW safe to dispose semaphores (no concurrent access possible)
_pauseLock.Dispose();
_renderLock.Dispose();
}SuggestionsNone - the overall synchronization design is sound. The Verdict |
Replace SemaphoreSlim-based synchronization with a cleaner signal-based design that cannot deadlock: - Remove _renderLock and _pauseLock semaphores - Use simple object lock (_pauseStateLock) held only briefly for state changes - Track _inRefresh flag to know if progress loop is in ctx.Refresh() - PauseAsync waits for _refreshCompleted signal (not a lock) - Progress loop signals _refreshCompleted after ctx.Refresh() completes This design ensures: - Lock is never held during slow operations (ctx.Refresh()) - No blocking on locks that might be held by slow terminal I/O - Clear signal-based handoff between pause request and completion
SummaryFixes console output glitches by adding coordinated synchronization between progress rendering and module output, using signal-based coordination to prevent text corruption and markup rendering issues. Critical IssuesNone found ✅ Suggestions1. Consider edge case in DisposeAsync with concurrent PauseAsync In ProgressSession.cs:353-357, the DisposeAsync method signals resume under lock, but there's a potential race if PauseAsync is called concurrently between the lock release and the actual disposal: lock (_pauseStateLock)
{
_isPaused = false;
_resumeSignal?.TrySetResult();
}
// <-- PauseAsync could create new _resumeSignal here
// Then awaiting would hang indefinitely on the new signalWhile unlikely in practice (DisposeAsync should be final), you could make it more defensive by also clearing the signal: lock (_pauseStateLock)
{
_isPaused = false;
_resumeSignal?.TrySetResult();
_resumeSignal = null; // Add this
_refreshCompleted = null; // Add this
}2. Minor: Timeout handling doesn't clear _inRefresh flag In the timeout path (ProgressSession.cs:234-244), when forcing resume after 60 seconds, the code clears pause state but doesn't reset Previous Review StatusThe previous review identified critical issues with render lock handling and semaphore cleanup. This has been completely refactored to use a lock-based signal coordination approach, which eliminates those issues. The new implementation properly:
Verdict✅ APPROVE - The refactored synchronization design is sound. The suggestions are minor defensive improvements, not blocking issues. |
The root cause of the CI hang was a blocking sync-over-async call in ModuleLogger.Dispose(). When the DI container disposed scoped services, it called Dispose() synchronously which used .GetAwaiter().GetResult() to block on the async EnqueueAndFlushAsync() method. Changes: - Make ModuleLogger implement IAsyncDisposable - Move async flush logic to DisposeAsync() which is called by the DI container when using AsyncServiceScope - Make sync Dispose() non-blocking (just marks as disposed) - Remove finalizer since Dispose() no longer blocks The Microsoft DI container prefers DisposeAsync() when a service implements IAsyncDisposable, so the normal disposal path will now be fully async with no blocking calls.
SummaryThis PR fixes console output glitches by introducing signal-based coordination between progress rendering and module output, replacing the previous render lock approach with a more robust state machine using TaskCompletionSource signals. Critical IssuesNone found ✅ Suggestions1. Minor edge case in DisposeAsync with concurrent operations In ProgressSession.cs:353-357, while unlikely, there's a theoretical race if PauseAsync is called concurrently during disposal between releasing the lock and actual task completion: lock (_pauseStateLock)
{
_isPaused = false;
_resumeSignal?.TrySetResult();
// Consider adding:
// _resumeSignal = null;
// _refreshCompleted = null;
}This would make the cleanup more defensive, though disposal should be the final operation. 2. Timeout handling could reset _inRefresh for consistency In the timeout path (around line 234-244), when forcing resume after 60 seconds, consider adding Previous Review StatusThe PR has already been reviewed and approved by thomhurst. The previous review identified critical concurrency issues with the initial render lock implementation, which were addressed by refactoring to the current signal-based coordination approach. The new design properly:
Verdict✅ APPROVE - The refactored synchronization design is sound and addresses the console glitching issues effectively. The suggestions above are minor defensive improvements, not blocking issues. |
- Add Microsoft.Testing.Extensions.HangDump to all test projects - Configure 20-minute hang dump timeout in test execution - Upload hang dump artifacts in GitHub Actions workflow
SummaryThis PR fixes console output glitches (#2162) by improving synchronization between progress rendering and module output through better pause/resume coordination and proper async disposal. Critical Issues1. Potential deadlock in ProgressSession.PauseAsync() - Race condition with double-wait In lock (_pauseStateLock)
{
if (_isPaused)
{
// Already paused - if there's an in-flight refresh, wait for it
waitForRefresh = _refreshCompleted; // ⚠️ Could be from previous pause
}
else
{
_isPaused = true;
_resumeSignal = new TaskCompletionSource(...);
if (_inRefresh)
{
_refreshCompleted = new TaskCompletionSource(...);
waitForRefresh = _refreshCompleted;
}
}
}
if (waitForRefresh != null)
{
await waitForRefresh.Task.ConfigureAwait(false); // ⚠️ Could wait on stale TCS
}Problem: If Fix: Only wait on lock (_pauseStateLock)
{
if (_isPaused)
{
return; // Already paused, nothing to do
}
_isPaused = true;
_resumeSignal = new TaskCompletionSource(...);
if (_inRefresh)
{
_refreshCompleted = new TaskCompletionSource(...);
waitForRefresh = _refreshCompleted;
}
}2. Missing volatile field or memory barrier for In private volatile bool _isFlushingOutput; // ⚠️ Missing 'volatile'It's read in Fix: Mark the field as private volatile bool _isFlushingOutput;3. Missing null check for In Fix: Add null check or ArgumentNullException in constructor: _consoleCoordinator = consoleCoordinator ?? throw new ArgumentNullException(nameof(consoleCoordinator));Suggestions1. Consider using In 2. Timeout behavior discrepancy The old code (line 161-178 removed) used
This is fine, but be aware the exception type changed from timeout to cancellation. Previous Review StatusUnable to fetch previous comments (GitHub API permissions). Verdict
|
- Fix race condition in PauseAsync: return early if already paused to avoid waiting on stale TaskCompletionSource from previous pause - Add ArgumentNullException checks in OutputCoordinator constructor - Reset _inRefresh in timeout path for state consistency - Clear signals in DisposeAsync to prevent concurrent PauseAsync hanging
SummaryThis PR fixes console output glitches (#2162) by coordinating synchronization between progress rendering and module output through the Critical Issues1. Missing In private volatile bool _isFlushingOutput; // Line 40 - missing 'volatile'This field is:
Without Fix: Mark the field as private volatile bool _isFlushingOutput;2. Missing null guard for In _consoleCoordinator = consoleCoordinator ?? throw new ArgumentNullException(nameof(consoleCoordinator));If this line is missing from the actual code, you'll get Verification needed: Confirm the constructor includes this null check as shown in the diff. Suggestions1. Consider timeout handling consistency The 2. File operation log simplification Removing Module/Activity context from file operation logs (File.cs and Folder.cs changes) significantly cleans up the messages. However, if diagnosing which module performed a file operation becomes necessary, this information is now lost. Consider whether Activity context should be preserved in structured logging properties rather than the message template. Verdict
|
… OutputCoordinator Move IsFlushing state ownership from ConsoleCoordinator to OutputCoordinator, eliminating the circular dependency between these components. OutputCoordinator now owns the flushing state since it controls when flushing occurs. - Add IsFlushing property to IOutputCoordinator interface - OutputCoordinator manages _isFlushingOutput internally - Remove SetFlushingOutput from IConsoleCoordinator - ConsoleCoordinator reads IsFlushing from IOutputCoordinator
SummaryFixes console output glitches by improving synchronization between progress rendering and module output flushing, adds hang dump collection, and removes Activity/Module context from file operation logs. Critical Issues1. Finalizer removed but not from base class - CRITICAL BUGLocation: src/ModularPipelines/Logging/ModuleLogger.cs:894-910 The PR removes the finalizer from ModuleLogger but the base class ModuleLogger still has a finalizer at line 94-97 that calls Dispose(disposing: false). However, the derived class no longer has the Dispose(bool disposing) method - it was replaced with separate Dispose() and DisposeAsync() methods. Problem: The finalizer in the base class will still be called, but there is no Dispose(bool) method to handle it. This could lead to resource leaks or unexpected behavior. Fix: Remove the finalizer from the base class as well, or ensure proper finalization support with the new async disposal design. 2. PR description does not match the actual changesThe PR description mentions adding _renderLock SemaphoreSlim and using Interlocked.Exchange for _renderLockHeldByPause, but I don't see these in the diff. Instead, the changes use _pauseStateLock, _inRefresh, and _refreshCompleted. Question: Did you push the wrong branch or update the PR description for a different version? Suggestions
VerdictREQUEST CHANGES - Critical issue #1 must be fixed. Issue #2 needs clarification. |
Summary
Fixes #2162 - Console output glitches where progress rendering interferes with module output.
Issues Fixed:
ctx.Refresh()could interleave with module output causing text corruption[bold cyan]?[/]) instead of rendered(Module: Unknown, Activity: (null))Changes:
_renderLockSemaphoreSlim for mutual exclusion between progress rendering and output writesInterlocked.Exchangefor atomic lock ownership tracking (_renderLockHeldByPause)_isFlushingOutputflag to bypass buffering when writing module output directlyResumeAsync()to always release render lock even after progress loop timeoutTest plan
::group::) appear correctly