Skip to content

refactor: rewrite BrighterAsyncContext for correctness and modernization#4076

Merged
iancooper merged 3 commits intoBrighterCommand:masterfrom
thomhurst:refactor/brighter-async-context
Apr 26, 2026
Merged

refactor: rewrite BrighterAsyncContext for correctness and modernization#4076
iancooper merged 3 commits intoBrighterCommand:masterfrom
thomhurst:refactor/brighter-async-context

Conversation

@thomhurst
Copy link
Copy Markdown
Contributor

Summary

Rewrites BrighterAsyncContext and its collaborators in src/Paramore.Brighter/Tasks/ to fix correctness bugs inherited from the AsyncEx-derived original implementation and modernize the code for current .NET conventions. Adds a thorough test suite covering shutdown races, concurrent-Execute, nested Run, and a 1000-iteration stress test.

Correctness fixes

  • Outstanding-operations counter leakEnqueue now balances the counter when TryAdd fails on a closed queue. The previous code left the counter stuck and Run would hang forever.
  • Late Post after shutdownBrighterTaskQueue.TryAdd / CompleteAdding / GetScheduledTasks tolerate both InvalidOperationException (completed-for-adding) and ObjectDisposedException (queue disposed), in the correct subclass-first catch order. Stray async continuations posting back after Run returns are absorbed silently.
  • Late Send after shutdown — now throws ObjectDisposedException instead of blocking indefinitely on a task the pump will never execute.
  • Exception swallowing in Run(Func<Task>) / Run<T>(Func<Task<T>>) — continuation now does try { WaitAndUnwrapException } finally { OperationCompleted }, so OperationCompleted cannot mask the user's exception.
  • Concurrent Execute — enforces the single-thread invariant via Interlocked.CompareExchange, throwing InvalidOperationException on re-entry instead of silently violating it.
  • Idempotent Dispose — guarded by Interlocked.Exchange(ref _disposed, 1); double-dispose no longer throws ObjectDisposedException from the underlying BlockingCollection.
  • GetScheduledTasks — takes a ToArray() snapshot rather than iterating the live BlockingCollection (which is undocumented against concurrent Add/Take), and absorbs ObjectDisposedException.
  • TaskExtensions.WaitAndUnwrapException(CancellationToken) — uses Task.WaitAsync(ct) on net6+; on netstandard2.0 unwraps via ex.InnerException (the previous code captured the wrapping AggregateException instead, double-wrapping the thrown exception).

Modernization

  • BrighterAsyncContext and BrighterSynchronizationContext are now sealed.
  • Enqueue, OperationStarted, OperationCompleted, GetScheduledTasks on BrighterAsyncContext moved from public to internal.
  • Tuple<Task, bool> replaced with (Task, bool) ValueTuple on the hot path.
  • Static lambdas with state params eliminate per-Enqueue closure allocations.
  • ArgumentNullException.ThrowIfNull under #if NET with netstandard2.0 fallbacks.
  • XML docs rewritten for accuracy; MIT attribution block added to TaskExtensions.
  • Removed dead code: ExecuteImmediately, TryExecuteNewThread, BrighterSynchronizationContext.Timeout, OutstandingOperations setter.

⚠️ Breaking changes (major bump)

  • BrighterAsyncContext and BrighterSynchronizationContext are now sealed.
  • Enqueue, OperationStarted, OperationCompleted, GetScheduledTasks went from public to internal.
  • ExecuteImmediately removed.
  • BrighterSynchronizationContext.Timeout property removed.
  • OutstandingOperations setter removed (getter kept and now returns a correct Volatile.Read of the internal counter; before, the setter was a dead auto-prop that always returned 0).

No in-repo consumers use any of the removed/tightened surfaces beyond Run(...) and Current.

Test coverage

Added (6 cases):

  • Post_AfterContextDisposed_DoesNotThrow
  • Send_AfterContextDisposed_Throws
  • Send_AfterShutdown_StressIterations_NeverHangsAndOnlyThrowsObjectDisposed — 1000 iterations, 5s bounded wait per iteration
  • Dispose_CalledTwice_DoesNotThrow
  • Run_NestedInsideOuterRun_DoesNotDeadlock
  • Execute_CalledConcurrently_ThrowsInvalidOperationException

Test plan

  • Build clean across all TFMs: netstandard2.0, net8.0, net9.0, net10.0.
  • BrighterSynchronizationContextsTests31/31 passed in ~1s.
  • Proactor regression smoke — 38/38 passed in ~30s.

Commits

  1. build: bump OpenTelemetry packages to 1.15.3 for GHSA-g94r-2vxg-569j — CVE fix required for master to restore. Happy to split into a separate PR if preferred.
  2. refactor: rewrite BrighterAsyncContext for correctness and modernization — the rewrite itself.

Review process

This PR was developed with multiple review passes (code-reuse, code-quality, efficiency, and three thorough correctness passes). Each pass surfaced specific findings that were addressed before the next. Review trail visible in the commit history if useful.

codescene-delta-analysis[bot]

This comment was marked as outdated.

@iancooper
Copy link
Copy Markdown
Member

Thanks @thomhurst I'll take a peek.

Copy link
Copy Markdown
Member

@iancooper iancooper left a comment

Choose a reason for hiding this comment

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

Thanks @thomhurst. I was a aware of a couple of cases where we seemed to block the thread, so its great that you managed to track them down. The additional operation is an interesting solution to one of the problems

@thomhurst
Copy link
Copy Markdown
Contributor Author

Code review

Found 1 issue:

  1. BrighterAsyncContext.Enqueue attaches the OperationCompleted continuation before calling _taskQueue.TryAdd. If TryAdd fails, the code calls OperationCompleted() manually to balance the counter, but the ContinueWith is still wired to the task. The stranded task can later be run via BrighterTaskScheduler.TryExecuteTaskInline (e.g. a caller doing task.Wait() while BrighterAsyncContext.Current == _asyncContext), which fires the continuation a second time and decrements _outstandingOperations below zero. The next OperationCompleted() that hits zero in normal flow will spuriously call CompleteAdding() while operations are still in flight, closing the queue prematurely.

    TryExecuteTaskInline only checks BrighterAsyncContext.Current == _asyncContext; it does not require the task to be in the queue:

    protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued)
    {
    #if DEBUG_CONTEXT
    Debug.IndentLevel = 1;
    Debug.WriteLine($"BrighterTaskScheduler: TryExecuteTaskInline on thread {Thread.CurrentThread.ManagedThreadId}");
    Debug.IndentLevel = 0;
    #endif
    return BrighterAsyncContext.Current == _asyncContext && TryExecuteTask(task);
    }

    Enqueue ordering (continuation attached before TryAdd, manual decrement on failure):

    /// </summary>
    public static BrighterAsyncContext? Current =>
    (SynchronizationContext.Current as BrighterSynchronizationContext)?.AsyncContext;
    /// <summary>
    /// Enqueues a task for execution by this context.
    /// </summary>
    /// <param name="task">The task to enqueue.</param>
    /// <param name="propagateExceptions">Whether to propagate exceptions back to <see cref="Execute"/>.</param>
    internal void Enqueue(Task task, bool propagateExceptions)
    {
    #if DEBUG_CONTEXT
    Debug.IndentLevel = 1;
    Debug.WriteLine($"BrighterAsyncContext: Enqueueing task {task.Id} on thread {Thread.CurrentThread.ManagedThreadId} for context {Id}");
    Debug.IndentLevel = 0;
    #endif
    OperationStarted();
    // Attach completion continuation before queueing. The task cannot start until the
    // scheduler pulls it from the queue, so no completion race is possible here.
    task.ContinueWith(
    static (_, state) => ((BrighterAsyncContext)state!).OperationCompleted(),
    this,
    CancellationToken.None,
    TaskContinuationOptions.ExecuteSynchronously,
    _taskScheduler);
    if (!_taskQueue.TryAdd(task, propagateExceptions))
    {
    // Queue is already completed for adding: the task will never be pulled, so the
    // continuation will never fire. Balance the outstanding-operations counter
    // manually so callers of Execute do not hang. Record shutdown so Send can
    // distinguish "pump will process this task" from "task will never run".
    Volatile.Write(ref _shutdown, 1);
    OperationCompleted();
    }
    }

    Suggested fix: detach/cancel the continuation on TryAdd failure, or attach it only after a successful TryAdd (with the task observed for completion separately).

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

codescene-delta-analysis[bot]

This comment was marked as outdated.

Rewrites BrighterAsyncContext and its collaborators (SynchronizationContext,
TaskScheduler, TaskQueue, Scope, TaskExtensions, TaskFactoryExtensions) to
fix correctness bugs in the AsyncEx-derived implementation and bring the
code in line with modern .NET conventions.

Correctness fixes:
- Outstanding-operations counter no longer leaks when TryAdd fails on a
  completed queue (previously caused shutdown hangs).
- Late Post after the pump shuts down is absorbed silently (TryAdd /
  CompleteAdding now tolerate both InvalidOperationException and
  ObjectDisposedException, in the correct catch order).
- Late Send after shutdown throws ObjectDisposedException instead of
  blocking forever on a task that will never run.
- Run(Func<Task>) / Run<T>(Func<Task<T>>) wrap the Unwrap proxy in
  try/finally so OperationCompleted cannot swallow the user's exception.
- Execute refuses re-entry via Interlocked.CompareExchange, enforcing
  the single-thread invariant.
- Dispose is idempotent.
- GetScheduledTasks takes a ToArray snapshot rather than iterating the
  live BlockingCollection, and absorbs ObjectDisposedException.

Modernization:
- All types sealed where appropriate.
- Public API tightened: internal on Enqueue / OperationStarted /
  OperationCompleted / GetScheduledTasks; removed unused ExecuteImmediately
  and Timeout/OutstandingOperations setters.
- Tuple<Task,bool> replaced with ValueTuple on the hot path.
- Static lambdas with state parameters eliminate per-Enqueue closure
  allocations.
- ArgumentNullException.ThrowIfNull (under #if NET) with netstandard2.0
  fallbacks.
- TaskExtensions.WaitAndUnwrapException(CancellationToken) uses
  Task.WaitAsync on net6+, drops the unreachable-after-Throw code path,
  and correctly unwraps the inner exception on netstandard2.0.
- XML docs rewritten for accuracy; MIT attribution added to TaskExtensions.

Tests:
- 6 new cases cover post-shutdown Post/Send, double-Dispose, nested Run,
  concurrent-Execute rejection, and a 1000-iteration Send-after-shutdown
  stress test. All 31 BrighterSynchronizationContextsTests and all 38
  Proactor smoke tests pass.

Breaking changes (major bump):
- BrighterAsyncContext and BrighterSynchronizationContext are now sealed.
- Enqueue, OperationStarted, OperationCompleted, GetScheduledTasks on
  BrighterAsyncContext went from public to internal.
- ExecuteImmediately removed.
- BrighterSynchronizationContext.Timeout property removed.
- OutstandingOperations setter removed (getter kept).
…yAdd fails

BrighterAsyncContext.Enqueue attached the OperationCompleted continuation
before calling TryAdd. If TryAdd failed, the counter was balanced manually,
but the continuation remained wired to the task. A caller that later
inline-executed the stranded task via TryExecuteTaskInline would fire the
continuation and decrement the counter a second time, underflowing it and
spuriously triggering CompleteAdding while operations were still in flight.

Attach the continuation only on the success path. ExecuteSynchronously
ensures the continuation still fires exactly once if the task is already
complete by the time we attach it.
@thomhurst thomhurst force-pushed the refactor/brighter-async-context branch from 658d45a to 2387e54 Compare April 25, 2026 13:21
codescene-delta-analysis[bot]

This comment was marked as outdated.

Copy link
Copy Markdown

@codescene-delta-analysis codescene-delta-analysis Bot left a comment

Choose a reason for hiding this comment

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

Code Health Improved (1 files improve in Code Health)

Gates Passed
4 Quality Gates Passed

See analysis details in CodeScene

View Improvements
File Code Health Impact Categories Improved
BrighterAsyncContext.cs 9.10 → 9.39 Code Duplication

Quality Gate Profile: Clean Code Collective
Install CodeScene MCP: safeguard and uplift AI-generated code. Catch issues early with our IDE extension and CLI tool.

@iancooper iancooper merged commit e0e9dcd into BrighterCommand:master Apr 26, 2026
25 of 28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants