Skip to content

Prevent FileLogger shutdown crash under thread-pool starvation#9802

Merged
Evangelink merged 8 commits into
mainfrom
dev/amauryleve/fix-filelogger-shutdown-crash
Jul 10, 2026
Merged

Prevent FileLogger shutdown crash under thread-pool starvation#9802
Evangelink merged 8 commits into
mainfrom
dev/amauryleve/fix-filelogger-shutdown-crash

Conversation

@Evangelink

Copy link
Copy Markdown
Member

Problem

Investigated from dotnet/sdk#55215: a Microsoft.Testing.Platform test host runs all tests successfully but then crashes during shutdown while disposing the diagnostic file logger:

Unhandled Exception: System.InvalidOperationException: Failed to flush logs before the timeout of '300' seconds
   at Microsoft.Testing.Platform.Logging.FileLogger.Dispose() ... FileLogger.cs:line 291

The process exits with 0xE0434352, turning a green run red.

Root cause

MTP has no net472 build, so .netfx test hosts load the netstandard2.0 MTP assembly, where #if NETCOREAPP is false and FileLogger is only IDisposable. On that path:

  • Dispose() blocks a thread-pool thread on _logLoop.Wait(5min).
  • The consumer loop was started with Task.Run and, after Complete(), its await WaitToReadAsync continuation is re-queued to the thread pool (RunContinuationsAsynchronously).

Under thread-pool starvation (many test processes running concurrently on a CI/Helix agent), that continuation can be delayed past the 5‑minute flush timeout while Dispose() holds a pool thread — so the flush times out and Dispose() throws, crashing an otherwise successful run.

Fix

  • Synchronous drain loop on the non-NETCOREAPP path. The file-logger consumer now runs a fully synchronous loop (WriteLogToFile) via Task.Run(Action). A synchronous delegate runs to completion on a single worker thread and blocks on the channel without ever scheduling a continuation, making shutdown immune to thread-pool starvation. Adds a blocking WaitToRead() (Monitor Wait/Pulse) to SingleConsumerUnboundedChannel<T> — purely additive; the existing async WaitToReadAsync used by AppInsights / AsyncConsumerDataProcessor is untouched.
  • Non-fatal flush timeout. Both Dispose() and DisposeAsync() now warn to the console instead of throwing when the flush times out. A logger failing to flush must never crash a passing test run.

The netcore path keeps its async loop and awaited DisposeAsync (which never blocks a thread), aside from the non-fatal guard.

Verification

  • MTP builds clean for net8.0 / net9.0 / netstandard2.0 (0 warnings, 0 errors).
  • FileLogger unit tests: 34/34 pass on net9.0 (async path) and 34/34 on net462 (the new synchronous drain path).

Notes

  • On the SDK side, adding --diagnostic-synchronous-write when --diagnostic is enabled remains a good complementary mitigation, but is no longer required to avoid the crash.
  • A dedicated-thread alternative (raw Thread / RunLongRunning) would trip CA1416 (browser) / IDE0060 on the shared constructor parameter; Task.Run(Action) with a blocking loop achieves the same starvation-immunity with minimal churn.

On the netstandard2.0 build (used by .NET Framework test hosts), FileLogger is disposed synchronously and Dispose() blocks on the async log loop via _logLoop.Wait(). Under thread-pool starvation (e.g. many test processes on a CI/Helix agent), the loop's continuation could be delayed past the flush timeout, causing Dispose() to throw and crash the test host on an otherwise successful run.

Run a fully synchronous drain loop on the non-NETCOREAPP path so the consumer never schedules a continuation and cannot be starved, and make the flush timeout non-fatal (warn instead of throw) in both Dispose() and DisposeAsync().

Relates to dotnet/sdk#55215

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 9, 2026 20:58

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 fixes a shutdown crash in Microsoft.Testing.Platform's diagnostic FileLogger on the netstandard2.0 build (used by .netfx test hosts). Under thread-pool starvation (many concurrent test processes on CI/Helix), the async consumer-loop continuation could be delayed past the 5-minute flush timeout while Dispose() blocked a pool thread, causing Dispose() to throw InvalidOperationException and turn a passing run red (ref: dotnet/sdk#55215).

Changes:

  • Adds a fully synchronous drain loop (WriteLogToFile) on the non-NETCOREAPP path, run via Task.Run(Action), plus a new blocking WaitToRead() (Monitor Wait/Pulse) on SingleConsumerUnboundedChannel<T> so shutdown can't be starved by a re-queued continuation. The existing async WaitToReadAsync path is untouched (the added Monitor.Pulse is a no-op for async consumers).
  • Makes the flush timeout non-fatal: both Dispose() and DisposeAsync() now warn to the console instead of throwing when the flush times out.
  • Registers the new internal API SingleConsumerUnboundedChannel<T>.WaitToRead() in InternalAPI.Unshipped.txt.
Show a summary per file
File Description
src/Platform/Microsoft.Testing.Platform/Logging/FileLogger.cs Splits the consumer loop into an async (NETCOREAPP) and a synchronous drain (WriteLogToFile) path; converts the flush-timeout throw in Dispose/DisposeAsync into a non-fatal console warning.
src/Platform/Microsoft.Testing.Platform/Messages/SingleConsumerUnboundedChannel.cs Adds a blocking WaitToRead() and Monitor.Pulse wake-ups in Write/Complete to support a synchronous single consumer; async path unchanged.
src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt Declares the new WaitToRead() internal API, consistent with the type's existing tracked members.

I verified the ITask.Run(Action) overload and its Task.Run(action) implementation exist, both resource strings are present, TimeoutAfterAsync throws TimeoutException (so the new catch is correct), the Monitor Wait/Pulse pattern is race-free for a single consumer (enqueue+pulse under the lock, condition re-checked in the while loop), and the new InternalAPI.Unshipped.txt entry follows the same #if !NETCOREAPP pattern already used by the type's shipped members. I found no concrete blocking defects. Note that this change alters process-shutdown semantics across multiple TFMs and introduces new Monitor-based blocking on a shared channel primitive, so it merits human sign-off.

Review details

  • Files reviewed: 3/3 changed files
  • Comments generated: 0
  • Review effort level: Medium

Comment thread src/Platform/Microsoft.Testing.Platform/Logging/FileLogger.cs Fixed
@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions github-actions Bot 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.

🤖 Automated content by GitHub Copilot. Generated by the Build Failure Analysis workflow. · 99.6 AIC · ⌖ 6.93 AIC · ⊞ 7.3K ·

Comment thread src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt Outdated

@github-actions github-actions Bot 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.

Note

🤖 Automated review by GitHub Copilot. Generated by the Expert Code Review workflow. To request a follow-up action, reply by tagging @copilot directly.

Review Summary

Overall: Clean PR with one acknowledged edge case. The fix is well-reasoned and the synchronous drain loop correctly addresses the root cause of the shutdown crash.

Dimensions Evaluated (14/22)

# Dimension Verdict
1 Algorithmic Correctness ✅ LGTM
2 Threading & Concurrency ⚠️ Observation (see inline)
3 Security & IPC Contract Safety ✅ LGTM
4 Public API & Binary Compatibility ✅ LGTM
5 Performance & Allocations ✅ LGTM
6 Cross-TFM Compatibility ✅ LGTM
7 Resource & IDisposable Management ⚠️ Same as Dim 2
8 Defensive Coding at Boundaries ✅ LGTM
9 Localization & Resources ✅ LGTM
13 Test Completeness ⚠️ Note below
15 Code Structure & Simplification ✅ LGTM
16 Naming & Conventions 📝 Nit (see inline)
17 Documentation Accuracy ✅ LGTM
21 Scope & PR Discipline ✅ LGTM

Dimensions 10–12, 14, 18–20, 22 skipped (not applicable — no test/analyzer/build/PS1 files changed).

Key Notes

Threading (non-blocking observation): On the timeout path, Dispose() proceeds to dispose _writer/_fileStream while the consumer loop may still be running. This is a known trade-off that is strictly better than the old behavior (crash via InvalidOperationException). The sync loop fix makes the timeout practically unreachable.

Test coverage: The new WaitToRead() method and non-fatal timeout behavior have no direct unit tests. The Monitor Wait/Pulse pattern is well-understood, but a basic test covering "returns true when items exist / blocks until item arrives / returns false after Complete()" would guard against future regressions.

InternalAPI sort order (nit): The Messages namespace entry should appear before ServerMode/Services entries alphabetically.

Comment thread src/Platform/Microsoft.Testing.Platform/Logging/FileLogger.cs Outdated
Comment thread src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt Outdated
- Declare SingleConsumerUnboundedChannel<T>.WaitToRead() in the Telemetry project's InternalAPI.Unshipped.txt (the file is linked into that assembly too), fixing the RS0051 build break.
- Capture _channel into a non-null local in WriteLogToFile to make null-safety explicit for external analyzers.
- Move the new InternalAPI entry to its sorted position.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 9, 2026 21:33
@Evangelink Evangelink enabled auto-merge (squash) July 9, 2026 21:34

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.

Review details

  • Files reviewed: 4/4 changed files
  • Comments generated: 2
  • Review effort level: Medium

Comment thread src/Platform/Microsoft.Testing.Platform/Logging/FileLogger.cs Outdated
Comment thread src/Platform/Microsoft.Testing.Platform/Logging/FileLogger.cs Outdated
On flush timeout the consumer loop is still running and owns _writer/_fileStream. Falling through to Flush/Dispose them raced with the loop (StreamWriter and its stream aren't thread-safe), which could throw or truncate the log. On timeout we now warn and return without disposing those resources, leaving them for the OS to reclaim at process exit. No data is lost because the writer uses AutoFlush.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 10, 2026 08:59

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.

Review details

  • Files reviewed: 4/4 changed files
  • Comments generated: 4
  • Review effort level: Low

Comment thread src/Platform/Microsoft.Testing.Platform/Logging/FileLogger.cs
Comment thread src/Platform/Microsoft.Testing.Platform/Logging/FileLogger.cs Outdated
Comment thread src/Platform/Microsoft.Testing.Platform/Logging/FileLogger.cs Outdated
Comment thread src/Platform/Microsoft.Testing.Platform/Logging/FileLogger.cs Outdated
Evangelink and others added 2 commits July 10, 2026 11:25
…lelogger-shutdown-crash

# Conflicts:
#	src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt
- HIGH: start the file-logger consumer loop only after _writer is fully initialized. Task.Run could schedule the loop on another thread before _writer was assigned later in the constructor, risking a null-writer dereference. The channel is still created early so Log() can enqueue; only the loop start moves to the end.
- Dispose the semaphore on the flush-timeout early-return paths in Dispose()/DisposeAsync() to avoid a managed-resource leak. It is not shared with the consumer loop (SyncFlush-only), so it's safe to dispose while the loop still owns the writer/stream.
- Fix a stale comment: on NETCOREAPP the channel is completed via TryComplete().
- Add a chaos test that hammers the async-flush path from many threads then disposes, asserting every queued message is drained without loss or crash (repeated across iterations).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 10, 2026 09:34

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.

Review details

  • Files reviewed: 5/5 changed files
  • Comments generated: 1
  • Review effort level: Medium

Comment thread test/UnitTests/Microsoft.Testing.Platform.UnitTests/Logging/FileLoggerTests.cs Outdated
@github-actions

This comment has been minimized.

@github-actions

Copy link
Copy Markdown
Contributor

🔍 Build Failure Analysis

Summary — The build fails because the new Log_WhenAsyncFlush_ConcurrentLoggingIsDrainedOnDisposeWithoutLoss test in FileLoggerTests.cs triggers 4 distinct analyzer errors (duplicated across 2 TFMs → 13 total MSBuild errors).

Root cause 1: MSTEST0049 — Missing CancellationToken on blocking APIs

Task.Run(...) (line 298), startGate.Wait() (line 300), and Task.WaitAll(...) (line 309) all have overloads accepting a CancellationToken. The analyzer requires passing TestContext.CancellationToken so the test can be cancelled cooperatively.

Affected errors (×2 TFMs = 6 total)

Code Line API
MSTEST0049 298 Task.Run(...)
MSTEST0049 300 startGate.Wait()
MSTEST0049 309 Task.WaitAll(producers)

Root cause 2: CA1416 — Platform-unsupported APIs (browser)

ManualResetEventSlim.Wait() and Task.WaitAll() are annotated as unsupported on browser. Since this test project never runs on a browser, the cleanest fix is a #pragma warning disable CA1416 around the affected block.

Affected errors (×2 TFMs = 4 total)

Code Line API
CA1416 300 ManualResetEventSlim.Wait()
CA1416 309 Task.WaitAll(...)

Root cause 3: MSTEST0037 — Use Assert.HasCount instead of Assert.AreEqual for collection count

Line 316 compares lines.Length with Assert.AreEqual. The analyzer requires Assert.HasCount (used extensively elsewhere in this project).

Affected errors (×2 TFMs = 2 total)

Code Line Message
MSTEST0037 316 Use Assert.HasCount instead of Assert.AreEqual

Proposed fixAssert.HasCount(producerCount * messagesPerProducer, lines) (note: lines not lines.Length).

Concrete fix summary

All 13 errors are in a single file: test/UnitTests/Microsoft.Testing.Platform.UnitTests/Logging/FileLoggerTests.cs. The fixes are:

  1. Pass TestContext.CancellationToken to Task.Run, startGate.Wait, and Task.WaitAll.
  2. Add #pragma warning disable CA1416 / restore around the blocking synchronization block.
  3. Replace Assert.AreEqual(..., lines.Length, ...) with Assert.HasCount(..., lines, ...).

See inline suggestions below.


Build overview
  • Result: FAILED (266.1s)
  • MSBuild: 18.10.0-preview-26359-110
  • Projects: 49 total, 1 failed (Microsoft.Testing.Platform.UnitTests.csproj)
  • Errors: 13 (4 unique, duplicated across 2 TFMs)
  • Warnings: 1
All MSBuild errors (13)
Code File:Line Message
MSTEST0049 FileLoggerTests.cs:298 Consider using the overload that accepts a CancellationToken
MSTEST0049 FileLoggerTests.cs:300 Consider using the overload that accepts a CancellationToken
MSTEST0049 FileLoggerTests.cs:309 Consider using the overload that accepts a CancellationToken
MSTEST0037 FileLoggerTests.cs:316 Use 'Assert.HasCount' instead of 'Assert.AreEqual'
CA1416 FileLoggerTests.cs:300 'ManualResetEventSlim.Wait()' is unsupported on: 'browser'
CA1416 FileLoggerTests.cs:309 'Task.WaitAll(params Task[])' is unsupported on: 'browser'
(above 6 repeated for 2nd TFM)

🤖 Generated by the Build Failure Analysis workflow using (a href="(dev.azure.com/redacted) · commit 2b65b17

🤖 Automated content by GitHub Copilot. Generated by the Build Failure Analysis workflow. · 113.5 AIC · ⌖ 6.39 AIC · ⊞ 7.3K · [◷]( · )

@github-actions github-actions Bot 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.

🤖 Automated content by GitHub Copilot. Generated by the Build Failure Analysis workflow. · 113.5 AIC · ⌖ 6.39 AIC · ⊞ 7.3K ·

Comment thread test/UnitTests/Microsoft.Testing.Platform.UnitTests/Logging/FileLoggerTests.cs Outdated
Comment thread test/UnitTests/Microsoft.Testing.Platform.UnitTests/Logging/FileLoggerTests.cs Outdated
Comment thread test/UnitTests/Microsoft.Testing.Platform.UnitTests/Logging/FileLoggerTests.cs Outdated
Comment thread test/UnitTests/Microsoft.Testing.Platform.UnitTests/Logging/FileLoggerTests.cs Outdated
Comment thread test/UnitTests/Microsoft.Testing.Platform.UnitTests/Logging/FileLoggerTests.cs Outdated
- Assert against the exact set of message payloads (parsed from each log line) instead of a substring check on the whole content, so prefix-overlapping IDs (P0M1 vs P0M10) can't mask loss/duplication. Also assert the actual-message set has the expected count to catch duplicates.
- Use Assert.HasCount instead of Assert.AreEqual for the line count (MSTEST0037).
- Pass TestContext.CancellationToken to Task.Run, ManualResetEventSlim.Wait, and Task.WaitAll (MSTEST0049), with CA1416 suppressions since this test never targets browser.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 10, 2026 13:01

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.

Review details

  • Files reviewed: 5/5 changed files
  • Comments generated: 5
  • Review effort level: Medium

Comment thread src/Platform/Microsoft.Testing.Platform/Logging/FileLogger.cs
Comment thread src/Platform/Microsoft.Testing.Platform/Logging/FileLogger.cs
Comment thread src/Platform/Microsoft.Testing.Platform/Logging/FileLogger.cs
Comment thread src/Platform/Microsoft.Testing.Platform/Logging/FileLogger.cs Outdated
Comment thread src/Platform/Microsoft.Testing.Platform/Logging/FileLogger.cs Outdated
@github-actions

This comment has been minimized.

Address review feedback on the FileLogger shutdown path:
- Expose FileLogger.IsFileHandleReleased so callers can tell when a flush timeout left the consumer still owning the file. FileLoggerProvider.CheckLogFolderAndMoveToTheNewIfNeededAsync now skips the MoveFile (which would throw an IOException on Windows because the stream is opened with FileShare.Read) when the handle wasn't released, keeping the timeout non-fatal.
- Make the FileLogger write-loop exception message path-neutral instead of naming WriteLogToFileAsync, which was wrong on the netstandard synchronous path. Regenerated xlf files.
- Correct the timeout comments: AutoFlush only guarantees already-written records are on disk; records still queued may be lost if the process exits before the loop drains them.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 10, 2026 13:33

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.

Review details

  • Files reviewed: 20/20 changed files
  • Comments generated: 2
  • Review effort level: Medium

Comment thread src/Platform/Microsoft.Testing.Platform/Logging/FileLoggerProvider.cs Outdated
@github-actions

Copy link
Copy Markdown
Contributor

🧪 Test quality grade — PR #9802

GradeTestNotes
B (80–89) new FileLoggerTests.
Log_
WhenAsyncFlush_
ConcurrentLoggingIsDrainedOnDisposeWithoutLoss
Strong assertion coverage (count + per-message content + uniqueness checks); body is ~80 lines — consider extracting the per-iteration assertion block into a helper to improve readability.

This advisory comment was generated automatically. Grades are heuristic
and informational — they do not block merging. Re-run with
/grade-tests.

🤖 Automated content by GitHub Copilot. Generated by the Grade Tests on PR (on open / sync) workflow. · 68.2 AIC · ⌖ 6.59 AIC · ⊞ 9.5K · [◷]( · )

Address review feedback:
- FileLoggerProvider.CheckLogFolderAndMoveToTheNewIfNeededAsync no longer returns early on a flush timeout leaving a disposed logger in place (which would make every subsequent diagnostic write throw). It now always installs a fresh logger for the test result directory and only moves the old file when its handle was released, keeping logging alive on the degenerate timeout path.
- Add an injectable flushTimeout seam on FileLogger so the timeout path is testable deterministically.
- Add deterministic tests: assert the netstandard build starts the consumer via the synchronous ITask.Run(Action) overload (locking the anti-starvation fix that the async impl would not satisfy), assert IsFileHandleReleased after a clean dispose, and assert the flush timeout is non-fatal (no throw, warning emitted, handle reported not released) using a never-completing consumer and a short timeout.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 10, 2026 13:49
@Evangelink Evangelink disabled auto-merge July 10, 2026 13:51
@Evangelink Evangelink merged commit ed5b08d into main Jul 10, 2026
25 of 31 checks passed
@Evangelink Evangelink deleted the dev/amauryleve/fix-filelogger-shutdown-crash branch July 10, 2026 13:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants