Skip to content

LogFactory - Flush / FlushAsync asynchronous completion outside Target lock#6101

Merged
snakefoot merged 2 commits intoNLog:devfrom
snakefoot:FlushAsyncDeadlock
Feb 28, 2026
Merged

LogFactory - Flush / FlushAsync asynchronous completion outside Target lock#6101
snakefoot merged 2 commits intoNLog:devfrom
snakefoot:FlushAsyncDeadlock

Conversation

@snakefoot
Copy link
Contributor

@snakefoot snakefoot commented Feb 27, 2026

Trying to resolve NLog/NLog.Web#1118

@snakefoot snakefoot added bug Bug report / Bug fix and removed size/M labels Feb 27, 2026
@coderabbitai
Copy link

coderabbitai bot commented Feb 27, 2026

Walkthrough

Refactors parallel-flush exception aggregation to use a lazily-initialized, thread-safe exception collection and changes synchronous continuation invocations to asynchronously-started completions to avoid potential deadlocks during parallel flush/continuation flows. (≤50 words)

Changes

Cohort / File(s) Summary
AsyncHelpers
src/NLog/Common/AsyncHelpers.cs
Moved action = ExceptionGuard(action) after zero-iteration check; replaced eager exception list with nullable IList<Exception>? lazily initialized via Interlocked.CompareExchange; add exceptions under lock; compute combined exception from exceptions ?? ArrayHelper.Empty<Exception>(); final continuation invoked asynchronously to avoid deadlocks.
Logging Configuration
src/NLog/Config/LoggingConfiguration.cs
Replaced synchronous invocation of flush completion callback with asynchronous invocation via AsyncHelpers.StartAsyncTask; completion callback now runs after releasing target lock and logs flush result (with or without errors).
SplitGroupTarget
src/NLog/Targets/Wrappers/SplitGroupTarget.cs
Reworked CreateCountedContinuation to use nullable, lazily-initialized IList<Exception>? via Interlocked.CompareExchange; additions guarded by lock on the list after init; on completion calls GetCombinedException(exceptions ?? Internal.ArrayHelper.Empty<Exception>()).
Tests
tests/NLog.UnitTests/LogFactoryTests.cs
Added two tests verifying no deadlock when a nested synchronous Flush is invoked from within a Flush callback or from a synchronous FlushAsync continuation.

Sequence Diagram(s)

sequenceDiagram
    participant Caller
    participant ParallelWorker
    participant ExceptionAggregator
    participant Continuation

    Caller->>ParallelWorker: ForEachItemInParallel(action, continuation)
    ParallelWorker->>ParallelWorker: Execute per-item action
    alt item throws
        ParallelWorker->>ExceptionAggregator: Lazy-init exceptions (Interlocked) and add under lock
    end
    ParallelWorker->>ParallelWorker: Decrement pending count
    alt pending == 0
        ParallelWorker->>ExceptionAggregator: GetCombinedException(exceptions ?? empty)
        ParallelWorker->>Continuation: Start async task -> invoke continuation(combinedException|null)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I hopped through threads with careful paws,
Collected errors without a pause,
I start the end in a tiny async song,
So nested flushes won't hang for long,
Hooray — no deadlocks, hop along!

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive No pull request description was provided by the author, making it impossible to evaluate whether the description relates to the changeset. Add a pull request description explaining the deadlock issue being fixed, the changes made, and why signaling completion outside the lock prevents deadlocks.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title 'LogFactory - FlushAsync should signal completion outside target lock' accurately describes the main change: moving async completion signaling outside of target locks to prevent deadlocks.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
src/NLog/Config/LoggingConfiguration.cs (1)

979-995: The async completion pattern correctly avoids potential deadlocks.

The StartAsyncTask invocation ensures the flush completion callback executes outside the target lock context, which is the core fix for the deadlock scenario.

Similar to the suggestion for AsyncHelpers.cs, the two branches could be unified since invoking with lastException works for both null and non-null cases.

,

♻️ Optional simplification
                 if (completed)
                 {
                     // The flush callback happens while holding target lock, so execute the flush completion asynchronously to avoid potential deadlocks.
+                    var flushException = lastException;
                     if (lastException != null)
-                    {
                         InternalLogger.Warn("Flush completed with errors");
-                        AsyncHelpers.StartAsyncTask(s =>
-                        {
-                            ((AsyncContinuation)s).Invoke(lastException);
-                        }, flushCompletion);
-                    }
                     else
-                    {
                         InternalLogger.Debug("Flush completed");
-                        AsyncHelpers.StartAsyncTask(s =>
-                        {
-                            ((AsyncContinuation)s).Invoke(null);
-                        }, flushCompletion);
-                    }
+                    AsyncHelpers.StartAsyncTask(s =>
+                    {
+                        ((AsyncContinuation)s).Invoke(flushException);
+                    }, flushCompletion);
                 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/NLog/Config/LoggingConfiguration.cs` around lines 979 - 995, The two
conditional branches that call AsyncHelpers.StartAsyncTask can be simplified:
remove the duplicate branches and call AsyncHelpers.StartAsyncTask once,
invoking ((AsyncContinuation)s).Invoke(lastException) so it works for both error
and success (lastException may be null); keep the existing InternalLogger
message (choose Warn if lastException != null else Debug) or optionally set the
log before the single StartAsyncTask call, and reuse the same
AsyncContinuation/flushCompletion invocation to avoid duplicated code (refer to
AsyncHelpers.StartAsyncTask, AsyncContinuation, lastException, and
flushCompletion in LoggingConfiguration.cs).
src/NLog/Common/AsyncHelpers.cs (1)

221-237: Consider simplifying the two branches into a single async call.

Both branches perform the same operation—invoking the continuation with combinedException. Since combinedException is already null in the first branch, you can unify them.

♻️ Proposed simplification
                     var combinedException = GetCombinedException(exceptions ?? ArrayHelper.Empty<Exception>());
 
                         // The flush callback happens while holding target lock, so execute the flush completion asynchronously to avoid potential deadlocks.
-                        if (combinedException is null)
-                        {
-                            AsyncHelpers.StartAsyncTask(s =>
-                            {
-                                ((AsyncContinuation)s).Invoke(null);
-                            }, asyncContinuation);
-                        }
-                        else
+                        var flushException = combinedException;
+                        AsyncHelpers.StartAsyncTask(s =>
                         {
-                            AsyncHelpers.StartAsyncTask(s =>
-                            {
-                                ((AsyncContinuation)s).Invoke(combinedException);
-                            }, asyncContinuation);
-                        }
+                            ((AsyncContinuation)s).Invoke(flushException);
+                        }, asyncContinuation);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/NLog/Common/AsyncHelpers.cs` around lines 221 - 237, The two branches
calling AsyncHelpers.StartAsyncTask can be merged: remove the if/else and call
AsyncHelpers.StartAsyncTask once, passing a lambda that casts s to
AsyncContinuation and invokes it with combinedException (which may be null) and
passing asyncContinuation as the state; update the block around
combinedException, AsyncHelpers.StartAsyncTask, and AsyncContinuation to use
this single unified call.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/NLog/Common/AsyncHelpers.cs`:
- Around line 221-237: The two branches calling AsyncHelpers.StartAsyncTask can
be merged: remove the if/else and call AsyncHelpers.StartAsyncTask once, passing
a lambda that casts s to AsyncContinuation and invokes it with combinedException
(which may be null) and passing asyncContinuation as the state; update the block
around combinedException, AsyncHelpers.StartAsyncTask, and AsyncContinuation to
use this single unified call.

In `@src/NLog/Config/LoggingConfiguration.cs`:
- Around line 979-995: The two conditional branches that call
AsyncHelpers.StartAsyncTask can be simplified: remove the duplicate branches and
call AsyncHelpers.StartAsyncTask once, invoking
((AsyncContinuation)s).Invoke(lastException) so it works for both error and
success (lastException may be null); keep the existing InternalLogger message
(choose Warn if lastException != null else Debug) or optionally set the log
before the single StartAsyncTask call, and reuse the same
AsyncContinuation/flushCompletion invocation to avoid duplicated code (refer to
AsyncHelpers.StartAsyncTask, AsyncContinuation, lastException, and
flushCompletion in LoggingConfiguration.cs).

ℹ️ Review info

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1594d92 and d828ea5.

📒 Files selected for processing (3)
  • src/NLog/Common/AsyncHelpers.cs
  • src/NLog/Config/LoggingConfiguration.cs
  • src/NLog/Targets/Wrappers/SplitGroupTarget.cs

Copy link

Copilot AI left a comment

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 adjusts NLog flush completion behavior to avoid invoking completion callbacks while holding target locks, reducing the risk of deadlocks during Flush / FlushAsync orchestration.

Changes:

  • Dispatches FlushAllTargets completion via ThreadPool instead of invoking inline under a target lock.
  • Updates AsyncHelpers.ForEachItemInParallel to invoke the final continuation asynchronously (and lazily allocate the exception list).
  • Lazily allocates exception aggregation lists for grouped continuations (e.g., SplitGroupTarget) to reduce allocations.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
src/NLog/Targets/Wrappers/SplitGroupTarget.cs Lazily initializes exception aggregation for counted continuations.
src/NLog/Config/LoggingConfiguration.cs Queues flush completion asynchronously to avoid executing under target lock.
src/NLog/Common/AsyncHelpers.cs Lazily initializes exception aggregation; queues final completion to avoid deadlocks; adds locking around combined-exception creation.

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

@sonarqubecloud
Copy link

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tests/NLog.UnitTests/LogFactoryTests.cs`:
- Around line 164-175: The test currently attaches a continuation to
logFactory.FlushAsync(...) but never asserts that the antecedent task completed
successfully; change the pattern to capture the antecedent Task (e.g., var
flushTask = logFactory.FlushAsync(CancellationToken.None)), attach the
ContinueWith to that task (preserving
TaskContinuationOptions.ExecuteSynchronously), then after waiting for
continuationCompleted assert that flushTask.IsCompletedSuccessfully (or
Assert.True(flushTask.Status == TaskStatus.RanToCompletion) / equivalent) before
asserting continuationFired and nestedFlushError to ensure the antecedent
succeeded and the regression is properly detected.

ℹ️ Review info

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 68cfe5a and 37dddfc.

📒 Files selected for processing (1)
  • tests/NLog.UnitTests/LogFactoryTests.cs

@sonarqubecloud
Copy link

@snakefoot snakefoot merged commit e30eab1 into NLog:dev Feb 28, 2026
6 of 7 checks passed
@snakefoot snakefoot changed the title LogFactory - FlushAsync should signal completion outside target lock LogFactory - Flush / FlushAsync asynchronous completion outside Target lock Feb 28, 2026
@snakefoot snakefoot added this to the 6.1.1 milestone Feb 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Bug report / Bug fix size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Application freezes after update to NLog.Web.AspNetCore 6.1.1

2 participants