Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions src/core/Akka.Tests/Loggers/StandardOutWriterSpec.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
//-----------------------------------------------------------------------
// <copyright file="StandardOutWriterSpec.cs" company="Akka.NET Project">
// Copyright (C) 2009-2022 Lightbend Inc. <http://www.lightbend.com>
// Copyright (C) 2013-2025 .NET Foundation <https://github.com/akkadotnet/akka.net>
// </copyright>
//-----------------------------------------------------------------------

using System;
using System.IO;
using System.Threading.Tasks;
using Akka.TestKit;
using Akka.Util;
using Xunit;
using Xunit.Abstractions;

namespace Akka.Tests.Loggers
{
/// <summary>
/// Tests for StandardOutWriter to ensure it handles IIS/Windows Service environments correctly
/// where Console.Out and Console.Error may be redirected to StreamWriter.Null
/// </summary>
public class StandardOutWriterSpec : AkkaSpec
{
public StandardOutWriterSpec(ITestOutputHelper output) : base(output)
{
}

[Fact]
public void StandardOutWriter_should_handle_concurrent_writes_without_race_conditions()
Copy link
Member Author

Choose a reason for hiding this comment

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

This whole test class is probably a tad useless IMHO given the racy and rare nature of this bug but it's worth a shot

{
// This test simulates the concurrent access pattern that causes issues in IIS
// In normal test environments this won't reproduce the issue, but it ensures
// our fix doesn't break normal console operation

var tasks = new Task[100];

for (int i = 0; i < tasks.Length; i++)
{
var taskId = i;
tasks[i] = Task.Run(() =>
{
for (int j = 0; j < 10; j++)
{
// These calls should not throw even under concurrent access
StandardOutWriter.WriteLine($"Task {taskId} - Line {j}");
StandardOutWriter.Write($"Task {taskId} - Write {j} ");
}
});
}

// Should complete without throwing IndexOutOfRangeException
Assert.True(Task.WaitAll(tasks, TimeSpan.FromSeconds(5)));
}

[Fact]
public void StandardOutWriter_should_not_throw_when_console_is_redirected()
{
// Save original streams
var originalOut = Console.Out;
var originalError = Console.Error;

try
{
// Simulate IIS/Windows Service environment by redirecting to null
Console.SetOut(StreamWriter.Null);
Console.SetError(StreamWriter.Null);

// These should not throw even when console is redirected to null
StandardOutWriter.WriteLine("This should not throw");
StandardOutWriter.Write("Neither should this");

// Test with colors (which would normally fail in IIS)
StandardOutWriter.WriteLine("Colored output", ConsoleColor.Red);
StandardOutWriter.Write("Colored write", ConsoleColor.Blue, ConsoleColor.Yellow);
}
finally
{
// Restore original streams
Console.SetOut(originalOut);
Console.SetError(originalError);
}
}

[Fact]
public void StandardOutWriter_should_handle_null_and_empty_messages()
Copy link
Member Author

Choose a reason for hiding this comment

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

Probably the most useful test of the bunch tbh

{
// Should not throw
StandardOutWriter.WriteLine(null);
StandardOutWriter.WriteLine("");
StandardOutWriter.Write(null);
StandardOutWriter.Write("");
}
}
}
6 changes: 5 additions & 1 deletion src/core/Akka/Event/DefaultLogger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,11 @@ protected override bool Receive(object message)
protected virtual void Print(LogEvent logEvent)
{
if (_stdoutLogger == null)
throw new Exception("Logger has not been initialized yet.");
{
// Include context about the failed log event to help with debugging
var logDetails = $"[{logEvent.LogLevel()}] {logEvent.LogSource}: {logEvent.Message}";
throw new Exception($"Logger has not been initialized yet. Failed to log: {logDetails}");
}

_stdoutLogger.Tell(logEvent);
}
Expand Down
39 changes: 39 additions & 0 deletions src/core/Akka/Util/StandardOutWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//-----------------------------------------------------------------------

using System;
using System.IO;

namespace Akka.Util
{
Expand All @@ -16,6 +17,34 @@ namespace Akka.Util
public static class StandardOutWriter
{
private static readonly object _lock = new();
private static readonly bool _isConsoleAvailable = DetectConsoleAvailability();

/// <summary>
/// Detects whether a real console is available for output.
/// In environments like IIS and Windows Services, console output is redirected to StreamWriter.Null,
/// which is a singleton. When multiple threads write to both Console.Out and Console.Error
/// (which point to the same StreamWriter.Null instance), it causes race conditions.
///
/// Since console output goes nowhere in these environments anyway, we skip it entirely
/// to prevent the race condition and improve performance.
/// </summary>
private static bool DetectConsoleAvailability()
{
// Specifically detect the IIS/Windows Service scenario where both Console.Out
// and Console.Error point to the SAME StreamWriter.Null singleton instance.
// This is the exact condition that causes the race condition.
// Note: We check both because in these environments, both are always set to the same instance
if (Console.Out == StreamWriter.Null && Console.Error == StreamWriter.Null)
Copy link
Member Author

Choose a reason for hiding this comment

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

Comment in the code explains it best

return false;

// Also check Environment.UserInteractive for additional safety
// This returns false for Windows Services and IIS in .NET Framework
// (though less reliable in .NET Core, the StreamWriter.Null check above is the key)
if (!Environment.UserInteractive)
return false;

return true;
}

/// <summary>
/// Writes the specified <see cref="string"/> value to the standard output stream. Optionally
Expand Down Expand Up @@ -46,6 +75,16 @@ public static void WriteLine(string message, ConsoleColor? foregroundColor = nul
private static void WriteToConsole(string message, ConsoleColor? foregroundColor = null,
ConsoleColor? backgroundColor = null, bool line = true)
{
// Skip console output in IIS, Windows Services, and other non-console environments.
// In these environments:
// 1. Console output is redirected to StreamWriter.Null (goes nowhere anyway)
// 2. Both Console.Out and Console.Error point to the same StreamWriter.Null singleton
// 3. Concurrent writes to both streams cause race conditions and IndexOutOfRangeException
// 4. Skipping output entirely prevents the race condition and improves performance
// See: https://github.com/akkadotnet/akka.net/issues/7691
if (!_isConsoleAvailable)
return;

lock (_lock)
{
ConsoleColor? fg = null;
Expand Down
Loading