Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
96 changes: 74 additions & 22 deletions libraries/src/AWS.Lambda.Powertools.Common/Core/ConsoleWrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ public class ConsoleWrapper : IConsoleWrapper
private static bool _override;
private static TextWriter _testOutputStream;
private static bool _inTestMode = false;
private static StreamWriter _stdoutWriter;
private static StreamWriter _stderrWriter;
private static readonly object _lock = new object();

/// <inheritdoc />
public void WriteLine(string message)
Expand Down Expand Up @@ -47,12 +50,7 @@ public void Error(string message)
}
else
{
if (!_override)
{
var errorOutput = new StreamWriter(Console.OpenStandardError());
errorOutput.AutoFlush = true;
Console.SetError(errorOutput);
}
EnsureStderrOutput();
Console.Error.WriteLine(message);
}
}
Expand All @@ -78,6 +76,32 @@ private static void EnsureConsoleOutput()
}
}

private static void EnsureStderrOutput()
{
if (_inTestMode) return;

var isLambda = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("AWS_LAMBDA_FUNCTION_NAME"));
if (!isLambda) return;

lock (_lock)
{
if (_stderrWriter != null) return;

try
{
_stderrWriter = new StreamWriter(Console.OpenStandardError())
{
AutoFlush = true
};
Console.SetError(_stderrWriter);
}
catch (Exception)
{
// Degraded functionality is better than crash
}
}
}
Comment thread
hjgraca marked this conversation as resolved.

private static bool ShouldOverrideConsole()
{
// Don't override if we're in test mode
Expand All @@ -96,13 +120,27 @@ internal static bool HasLambdaReInterceptedConsole()

internal static bool HasLambdaReInterceptedConsole(Func<TextWriter> consoleOutAccessor)
{
// Lambda might re-intercept console between init and handler execution
// Lambda might re-intercept console between init and handler execution.
// We need to detect when Lambda replaces our writer with its own,
// but NOT trigger on the SyncTextWriter wrapper that Console.SetOut
// always applies around our StreamWriter — that's still ours.
try
{
var currentOut = consoleOutAccessor();
// Check if current output stream looks like it might be Lambda's wrapper
var typeName = currentOut.GetType().FullName ?? "";
return typeName.Contains("Lambda") || typeName == "System.IO.TextWriter+SyncTextWriter";

// If it explicitly contains "Lambda", Lambda has re-intercepted
if (typeName.Contains("Lambda"))
return true;

// If we have a cached writer, check if Console.Out still wraps it.
// Console.SetOut wraps in SyncTextWriter, so seeing SyncTextWriter
// does NOT mean Lambda re-intercepted — it's our own writer wrapped.
// Only if _stdoutWriter is null (never set) do we need to override.
lock (_lock)
{
return _stdoutWriter == null;
}
}
catch
{
Expand All @@ -117,20 +155,32 @@ internal static void OverrideLambdaLogger()

internal static void OverrideLambdaLogger(Func<Stream> standardOutputOpener)
{
try
lock (_lock)
{
// Force override of LambdaLogger
var standardOutput = new StreamWriter(standardOutputOpener())
try
{
AutoFlush = true
};
Console.SetOut(standardOutput);
_override = true;
}
catch (Exception)
{
// Log the failure but don't throw - degraded functionality is better than crash
_override = false;
// Reuse existing writer if we already have one — avoids FD leak
if (_stdoutWriter != null)
{
// Re-set Console.Out in case Lambda replaced it
Console.SetOut(_stdoutWriter);
_override = true;
return;
}

// First time: create a single long-lived writer for stdout
_stdoutWriter = new StreamWriter(standardOutputOpener())
{
AutoFlush = true
};
Console.SetOut(_stdoutWriter);
_override = true;
}
catch (Exception)
{
// Log the failure but don't throw - degraded functionality is better than crash
_override = false;
}
}
}

Expand All @@ -147,6 +197,8 @@ public static void ResetForTest()
_override = false;
_inTestMode = false;
_testOutputStream = null;
_stdoutWriter = null;
_stderrWriter = null;
}

/// <summary>
Expand All @@ -157,4 +209,4 @@ public static void ClearOutputResetFlag()
// This method is kept for backward compatibility but no longer needed
// since we removed the _outputResetPerformed flag
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -332,4 +332,86 @@ public void OverrideLambdaLogger_WhenOpenStandardOutputThrows_ThenSetsOverrideTo
// Then - Should not throw (catch block handles it on lines 120-123)
Assert.Null(exception);
}

[Fact]
public void OverrideLambdaLogger_GivenMultipleCalls_ShouldOpenStdoutOnlyOnce_NoFdLeak()
{
// Regression test for https://github.com/aws-powertools/powertools-lambda-dotnet/issues/1143
// Before the fix, every call to OverrideLambdaLogger created a new
// StreamWriter(Console.OpenStandardOutput()), leaking one FD per log write.
// Under sustained load this exhausted the Lambda 1024 FD limit.

// Given
ConsoleWrapper.ResetForTest();
int openCount = 0;
Func<Stream> countingOpener = () =>
{
openCount++;
return new MemoryStream(); // stand-in for stdout
};

// When — simulate 500 log writes triggering OverrideLambdaLogger
for (int i = 0; i < 500; i++)
{
ConsoleWrapper.OverrideLambdaLogger(countingOpener);
}

// Then — stream should have been opened exactly once, not 500 times
Assert.Equal(1, openCount);
}

[Fact]
public void WriteLine_GivenLambdaEnvironment_MultipleWrites_ShouldNotLeakFileDescriptors()
{
// Regression test for https://github.com/aws-powertools/powertools-lambda-dotnet/issues/1143
// End-to-end: simulates the actual leak scenario from the issue where
// pipe FDs grew linearly with each ILogger log call in warm Lambda containers.

// Given
ConsoleWrapper.ResetForTest();
Environment.SetEnvironmentVariable("AWS_LAMBDA_FUNCTION_NAME", "test-function");
var wrapper = new ConsoleWrapper();

int openCount = 0;
// We can't hook into the real EnsureConsoleOutput path easily,
// but we can call OverrideLambdaLogger directly to prove the pattern.
Func<Stream> countingOpener = () =>
{
openCount++;
return new MemoryStream();
};

// When — first call opens the stream
ConsoleWrapper.OverrideLambdaLogger(countingOpener);
Assert.Equal(1, openCount);

// Subsequent calls should reuse the cached writer
for (int i = 0; i < 100; i++)
{
ConsoleWrapper.OverrideLambdaLogger(countingOpener);
}

// Then — still only 1 open, not 101
Assert.Equal(1, openCount);
}
Comment thread
hjgraca marked this conversation as resolved.

[Fact]
public void HasLambdaReInterceptedConsole_AfterOverride_ShouldNotFalsePositiveOnSyncTextWriter()
{
// Regression test for https://github.com/aws-powertools/powertools-lambda-dotnet/issues/1143
// The old code checked: typeName == "System.IO.TextWriter+SyncTextWriter"
// But Console.SetOut() ALWAYS wraps in SyncTextWriter, so this was a
// permanent false positive that caused OverrideLambdaLogger to fire on every call.

// Given — override has been applied (simulating first log write in Lambda)
ConsoleWrapper.ResetForTest();
ConsoleWrapper.OverrideLambdaLogger(() => new MemoryStream());

// When — check if Lambda "re-intercepted" (it didn't, we just set it ourselves)
// Console.Out is now SyncTextWriter wrapping our StreamWriter
var result = ConsoleWrapper.HasLambdaReInterceptedConsole();

// Then — should be false: our writer is still in place, Lambda didn't touch it
Assert.False(result);
}
}
Loading