Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
faf77aa
Initial plan
Copilot Mar 8, 2026
bc41dce
Fix NativeAOT OOM message not printed on Linux before Abort()
Copilot Mar 8, 2026
3ef2a6d
Add OomHandling smoke test for NativeAOT OOM message reporting
Copilot Mar 9, 2026
ac7e07e
Merge branch 'main' into copilot/fix-out-of-memory-reporting
eduardo-vp Apr 14, 2026
99a6529
Update minimalFailFast condition and test
Apr 15, 2026
57092c7
Add test timeout
Apr 15, 2026
cf2ca95
Revert changes to minimalFailFast
Apr 15, 2026
2eafa92
Fix test
Apr 16, 2026
cc43588
Show consistent error messages
Apr 17, 2026
15e81d4
Apply suggestion from @jkotas
jkotas Apr 17, 2026
f373a39
Code review feedback
Apr 20, 2026
aa3ce5e
Move test to src/tests/baseservices/exceptions
May 13, 2026
821b61d
Move test to src/tests/baseservices/exceptions, make it work correctl…
May 14, 2026
fb34a0d
Nit
May 14, 2026
c12f292
Adjust timeout and disable on Mono
May 14, 2026
88e7eae
Nit
May 14, 2026
5984be7
Update src/coreclr/nativeaot/System.Private.CoreLib/src/System/Runtim…
eduardo-vp May 28, 2026
027d91f
Make OOM minimal message consistent with CoreCLR
May 28, 2026
a2b7dec
Update comment
May 28, 2026
643046c
Merge branch 'main' into copilot/fix-out-of-memory-reporting
eduardo-vp May 29, 2026
1e2888e
Use Process.RunAndCaptureText
Jun 2, 2026
e3b0bd4
Potential fix for pull request finding
eduardo-vp Jun 2, 2026
ae0981e
Potential fix for pull request finding
eduardo-vp Jun 2, 2026
9559af2
Accept both minimal and standard OOM message
Jun 2, 2026
258f4ed
Update test
Jun 3, 2026
d370881
Potential fix for pull request finding
eduardo-vp Jun 3, 2026
a4111bb
Stop using lists and chain of objects
Jun 3, 2026
80b6dce
Avoid managed allocations in Internal.Console.Error.Write
Jun 4, 2026
08aa758
PR feedback
Jun 4, 2026
c943e14
Apply suggestion from @jkotas
jkotas Jun 4, 2026
5a8e077
Potential fix for pull request finding
eduardo-vp Jun 4, 2026
2701338
Pin objects
Jun 4, 2026
faf6e09
Merge branch 'main' into copilot/fix-out-of-memory-reporting
eduardo-vp Jun 6, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -201,16 +201,23 @@ internal static void SerializeCrashInfo(RhFailFastReason reason, string? message
int previousState = Interlocked.CompareExchange(ref s_crashInfoPresent, -1, 0);
if (previousState == 0)
{
CrashInfo crashInfo = new();
try
{
CrashInfo crashInfo = new();

crashInfo.Open(reason, Thread.CurrentOSThreadId, message ?? GetStringForFailFastReason(reason));
if (exception != null)
crashInfo.Open(reason, Thread.CurrentOSThreadId, message ?? GetStringForFailFastReason(reason));
Comment thread
eduardo-vp marked this conversation as resolved.
if (exception != null)
{
crashInfo.WriteException(exception);
}
crashInfo.Close();
s_triageBufferAddress = crashInfo.TriageBufferAddress;
s_triageBufferSize = crashInfo.TriageBufferSize;
}
catch
{
crashInfo.WriteException(exception);
// If crash info serialization fails (for example, due to OOM), proceed without it.
}
crashInfo.Close();
s_triageBufferAddress = crashInfo.TriageBufferAddress;
s_triageBufferSize = crashInfo.TriageBufferSize;

s_crashInfoPresent = 1;
}
Expand All @@ -235,8 +242,20 @@ internal static unsafe void FailFast(string? message = null, Exception? exceptio
ulong previousThreadId = Interlocked.CompareExchange(ref s_crashingThreadId, currentThreadId, 0);
if (previousThreadId == 0)
{
bool minimalFailFast = (exception == PreallocatedOutOfMemoryException.Instance);
if (!minimalFailFast)
bool minimalFailFast = exception == PreallocatedOutOfMemoryException.Instance;
if (minimalFailFast)
{
// Minimal OOM fail-fast path: avoid heap allocations as much as possible, but still
// report that OOM is the reason for the crash.
try
{
// Try to print the same short message CoreCLR prints.
Internal.Console.Error.Write("Out of memory.");
Internal.Console.Error.WriteLine();
Comment thread
eduardo-vp marked this conversation as resolved.
}
Comment thread
eduardo-vp marked this conversation as resolved.
Comment thread
eduardo-vp marked this conversation as resolved.
catch { }
Comment on lines +248 to +256
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We may want to address this by changing Internal.Console.Error.Write to avoid managed allocations for small strings on Unix. Notice that Windows implementation is like that already.

I suspect that the test may be flaky otherwise given that it tries to allocate every last bit of managed memory.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The test was flaky indeed but I think the problem was using a list that tried to resize from 2048 to 4096 and failed leaving a remaining size of still ~32 KB in memory, too big to start trying to do very tiny allocations. I think the GC was just thrashing so updated the test.

I also added this change so I'm expecting the test doesn't fail, will observe the CI.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The test consistently times out only on osx x64. At this point I'm wondering if there's a problem with the test or it's actually the runtime the one that is thrashing when it should OOM.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I would not be surprised if there are runtime bugs that lead to hang on OOM like the one this test is exercising.

}
else
{
Internal.Console.Error.Write(((exception == null) || (reason is RhFailFastReason.EnvironmentFailFast or RhFailFastReason.AssertionFailure)) ?
Comment thread
jkotas marked this conversation as resolved.
"Process terminated. " : "Unhandled exception. ");
Expand Down Expand Up @@ -266,8 +285,21 @@ internal static unsafe void FailFast(string? message = null, Exception? exceptio

if ((exception != null) && (reason is not RhFailFastReason.AssertionFailure))
{
Internal.Console.Error.Write(exception.ToString());
Internal.Console.Error.WriteLine();
try
{
Internal.Console.Error.Write(exception.ToString());
Internal.Console.Error.WriteLine();
}
catch
{
// If ToString() fails (for example, due to OOM), fall back to printing just the type name.
try
{
Internal.Console.Error.Write(exception.GetType().FullName);
Comment thread
eduardo-vp marked this conversation as resolved.
Internal.Console.Error.WriteLine();
}
Comment thread
eduardo-vp marked this conversation as resolved.
catch { }
}
}

#if TARGET_WINDOWS
Expand Down
31 changes: 19 additions & 12 deletions src/libraries/System.Private.CoreLib/src/Internal/Console.Unix.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,32 @@ namespace Internal
public static partial class Console
{
[MethodImplAttribute(MethodImplOptions.NoInlining)]
public static unsafe void Write(string s)
public static void Write(string s)
{
byte[] bytes = Encoding.UTF8.GetBytes(s);
fixed (byte* pBytes = bytes)
{
Interop.Sys.Log(pBytes, bytes.Length);
}
WriteCore(s, error: false);
}

public static partial class Error
{
[MethodImplAttribute(MethodImplOptions.NoInlining)]
public static unsafe void Write(string s)
public static void Write(string s)
{
WriteCore(s, error: true);
}
}

private static unsafe void WriteCore(string s, bool error)
{
int byteCount = Encoding.UTF8.GetByteCount(s);
Span<byte> bytes = (uint)byteCount < 1024 ? stackalloc byte[byteCount] : new byte[byteCount];
int cbytes = Encoding.UTF8.GetBytes(s, bytes);

fixed (byte* pBytes = bytes)
{
byte[] bytes = Encoding.UTF8.GetBytes(s);
fixed (byte* pBytes = bytes)
{
Interop.Sys.LogError(pBytes, bytes.Length);
}
if (error)
Interop.Sys.LogError(pBytes, cbytes);
else
Interop.Sys.Log(pBytes, cbytes);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Licensed to the .NET Foundation under one or more agreements.
Comment thread
eduardo-vp marked this conversation as resolved.
// The .NET Foundation licenses this file to you under the MIT license.

// This test verifies that an out-of-memory condition produces a diagnostic
// message on stderr before the process terminates.
//
// The test spawns itself as a subprocess with a small GC heap limit set via
// DOTNET_GCHeapHardLimit so that the subprocess reliably runs out of memory.
// The outer process then validates that the subprocess wrote the expected
// OOM message to its standard error stream.

using System;
using System.Collections.Generic;
using System.Diagnostics;

class OutOfMemoryExceptionTest
{
const int Pass = 100;
const int Fail = -1;
const int TimeoutMilliseconds = 60 * 1000;

const string AllocateSmallArg = "--allocate-small";
const string AllocateLargeArg = "--allocate-large";
// The standard unhandled-exception path ("Unhandled exception. System.OutOfMemoryException...")
// contains this token. The minimal OOM fail-fast path may only print a short "Out of memory." message.
// The test validates that some OOM diagnostic is printed rather than just "Aborted" with no context.
const string ExpectedOomToken = "OutOfMemoryException";
const string ExpectedMinimalOomToken = "Out of memory.";

static int Main(string[] args)
{
if (args.Length > 0 && args[0] == AllocateSmallArg)
{
// Pre-allocate a flat array for storage.
object[] storage = new object[8192];
int idx = 0;
// We expect ~2048 iterations in the first loop and ~64 iterations in the second.
try { while (idx < storage.Length) storage[idx++] = GC.AllocateArray<byte>(16 * 1024, pinned: true); } catch (OutOfMemoryException) { }
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is OOM caused by pinned: true able to hit the minimalFailFast?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Locally I'm seeing it sometimes hits the minimalFailFast, sometimes it outputs the full stack trace. But in any case the test still doesn't work on osx x64. Should we exclude the test from that config and file an issue?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should we exclude the test from that config and file an issue?

Sounds good

try { while (idx < storage.Length) storage[idx++] = GC.AllocateArray<byte>(256, pinned: true); } catch (OutOfMemoryException) { }
// < 280 bytes free.
// Use the smallest possible allocation to exhaust the last scraps.
while (idx < storage.Length) storage[idx++] = GC.AllocateArray<byte>(1, pinned: true);
return Fail;
}
Comment thread
eduardo-vp marked this conversation as resolved.

if (args.Length > 0 && args[0] == AllocateLargeArg)
{
// Subprocess mode: allocate 128 KB chunks until OOM is triggered.
// This leaves some free memory when OOM fires, exercising the code
// path where GetRuntimeException may allocate a new OutOfMemoryException.
var list = new List<byte[]>();
while (true) list.Add(new byte[128 * 1024]);
}

// Controller mode: launch subprocesses with a GC heap limit and verify their output.
int result = RunSubprocess(AllocateSmallArg, "small allocations");
if (result != Pass)
return result;

return RunSubprocess(AllocateLargeArg, "large allocations");
}

static int RunSubprocess(string allocateArg, string description)
{
Console.WriteLine($"Testing OOM with {description}...");

string fileName = Environment.ProcessPath;
Comment thread
eduardo-vp marked this conversation as resolved.
Comment thread
eduardo-vp marked this conversation as resolved.
string[] arguments = TestLibrary.Utilities.IsNativeAot
? [allocateArg]
: [typeof(OutOfMemoryExceptionTest).Assembly.Location, allocateArg];

Comment thread
eduardo-vp marked this conversation as resolved.
var psi = new ProcessStartInfo(fileName, arguments)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
};
// 32 MB GC heap limit (0x2000000): small enough to exhaust quickly but large enough for startup.
psi.Environment["DOTNET_GCHeapHardLimit"] = "0x2000000";
psi.Environment["DOTNET_DbgEnableMiniDump"] = "0";

ProcessTextOutput output;
try
{
output = Process.RunAndCaptureText(psi, TimeSpan.FromMilliseconds(TimeoutMilliseconds));
}
catch (TimeoutException)
{
Console.WriteLine($"Subprocess timed out after {TimeoutMilliseconds / 1000} seconds.");
return Fail;
}

if (output.ExitStatus.ExitCode == 0 || output.ExitStatus.ExitCode == Pass)
{
Console.WriteLine($"Subprocess exit code: {output.ExitStatus.ExitCode}");
Console.WriteLine($"Subprocess stderr: {output.StandardError}");
Console.WriteLine("Expected a non-success exit code from the OOM subprocess.");
return Fail;
}

string stderr = output.StandardError;

// Even in the small allocations case, the runtime might still have enough memory to construct
// an OutOfMemoryException and print the full diagnostic.
// Either token is acceptable, but at least one should be present to confirm that OOM was the reason for termination.
if (!(stderr.Contains(ExpectedOomToken) || stderr.Contains(ExpectedMinimalOomToken)))
Comment thread
eduardo-vp marked this conversation as resolved.
{
Comment on lines +102 to +106
Console.WriteLine($"Subprocess exit code: {output.ExitStatus.ExitCode}");
Console.WriteLine($"Subprocess stderr: {stderr}");
Console.WriteLine("Expected OOM diagnostic token not found in subprocess stderr.");
return Fail;
}

return Pass;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
Comment thread
eduardo-vp marked this conversation as resolved.
<PropertyGroup>
<OutputType>Exe</OutputType>
<CLRTestPriority>0</CLRTestPriority>
<!-- This test spawns a subprocess; not supported on mobile, browser, or WASI platforms -->
<CLRTestTargetUnsupported Condition="'$(TargetsAppleMobile)' == 'true' or '$(TargetsAndroid)' == 'true' or '$(TargetsBrowser)' == 'true' or '$(TargetsWasi)' == 'true'">true</CLRTestTargetUnsupported>
<RequiresProcessIsolation>true</RequiresProcessIsolation>
<ReferenceXUnitWrapperGenerator>false</ReferenceXUnitWrapperGenerator>
<!-- Mono doesn't enforce DOTNET_GCHeapHardLimit as a GC heap limit -->
<DisableProjectBuild Condition="'$(RuntimeFlavor)' == 'mono'">true</DisableProjectBuild>
</PropertyGroup>
<ItemGroup>
<Compile Include="OutOfMemoryException.cs" />
<ProjectReference Include="$(TestLibraryProjectPath)" />
</ItemGroup>
</Project>
Loading