Repository navigation
Description
Description
During thread start .NET runtime configures an alternate signal stack to receive messages from the operating system. If application code reconfigures the alternate signal stack in that same thread, the virtual memory allocation for the .NET runtime-allocated alternate signal stack is leaked on thread shutdown.
On Linux, CoreCLR's PAL allocates an alternate signal stack for every managed thread (CPalThread::EnsureSignalAlternateStack, called from CPalThread::ThreadEntry) and frees it from the thread's pthread TLS destructor (InternalEndCurrentThreadWrapper → CPalThread::FreeSignalAlternateStack). The free is conditional: it munmaps the mapping only if the kernel still reports the PAL's own stack as the registered, enabled alternate stack at that moment.
Any native library that runs on that thread and installs its own alternate stack with sigaltstack (Rust-based runtimes with their own trap handlers do this per thread: Wasmtime, and anything built on rustix/libc signal stacks) breaks that condition, whether it later disables its stack (SS_DISABLE) or just unmaps it. The PAL then skips its munmap, and the 16 KB mapping (one PROT_NONE guard page plus 12 KB rw-p) is leaked for the life of the process. It is leaked once per thread exit, so a thread-pool-heavy service leaks continuously as workers are retired and recreated.
Observed in production: an ASP.NET Core service on .NET 10.0.12 evaluating OPA policies through Wasmtime.Dotnet on thread-pool threads accumulated 41,057 such regions in one pod (481 MB of virtual address space, 0 resident), visible as unbounded growth of Process.PrivateMemorySize64 (which on Linux is VmData + VmStk) and of the process's VMA count against vm.max_map_count.
Reproduction Steps
Execute an unmanaged code path that configures an alternate signal stack
// dotnet run -c Release -- control 1000 # threads do nothing with sigaltstack -> no growth
// dotnet run -c Release -- disable 1000 # install own stack, SS_DISABLE, munmap -> +2 maps / +16 KB per thread
// dotnet run -c Release -- dangling 1000 # install own stack, munmap only (Wasmtime <= 22) -> same leak
using System.Runtime.InteropServices;
const int SS_DISABLE = 2;
const int PROT_READ = 1, PROT_WRITE = 2;
const int MAP_PRIVATE = 0x02, MAP_ANONYMOUS = 0x20;
const nint OwnStackSize = 256 * 1024;
string mode = args.Length > 0 ? args[0] : "disable";
int threads = args.Length > 1 ? int.Parse(args[1]) : 1000;
static long Maps() => File.ReadLines("/proc/self/maps").LongCount();
static long StatusKb(string key) => long.Parse(File.ReadLines("/proc/self/status").First(l => l.StartsWith(key)).Split(' ', StringSplitOptions.RemoveEmptyEntries)[1]);
void ThreadBody()
{
if (mode == "control")
{
return;
}
// What a native library with its own trap handler does on a thread that runs its code.
nint stack = Native.mmap(0, OwnStackSize, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
var install = new stack_t { ss_sp = stack, ss_flags = 0, ss_size = (nuint)OwnStackSize };
if (Native.sigaltstack(ref install, out _) != 0)
{
throw new InvalidOperationException($"sigaltstack(install) failed: errno {Marshal.GetLastWin32Error()}");
}
// ... and what it does when the thread exits:
if (mode == "disable")
{
var off = new stack_t { ss_sp = 0, ss_flags = SS_DISABLE, ss_size = 8192 };
Native.sigaltstack(ref off, out _);
}
Native.munmap(stack, OwnStackSize);
}
Console.WriteLine($"mode={mode} threads={threads} runtime={RuntimeInformation.FrameworkDescription} os={RuntimeInformation.OSDescription}");
long maps0 = Maps(), vmdata0 = StatusKb("VmData:");
Console.WriteLine($"before: maps={maps0} VmData={vmdata0} kB");
for (int i = 0; i < threads; i++)
{
var t = new Thread(ThreadBody);
t.Start();
t.Join();
}
long maps1 = Maps(), vmdata1 = StatusKb("VmData:");
Console.WriteLine($"after: maps={maps1} VmData={vmdata1} kB");
Console.WriteLine($"delta: maps=+{maps1 - maps0} ({(maps1 - maps0) / (double)threads:0.00} per thread) VmData=+{vmdata1 - vmdata0} kB");
// The leaked PAL stacks are 16 KB mappings: a 4 KB PROT_NONE guard page and a 12 KB rw-p region.
int guards = 0, bodies = 0;
foreach (var line in File.ReadLines("/proc/self/maps"))
{
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var range = parts[0].Split('-');
long size = Convert.ToInt64(range[1], 16) - Convert.ToInt64(range[0], 16);
if (parts.Length == 5 && size == 4096 && parts[1] == "---p") guards++;
if (parts.Length == 5 && size == 12 * 1024 && parts[1] == "rw-p") bodies++;
}
Console.WriteLine($"anonymous 4 KB ---p mappings: {guards}; anonymous 12 KB rw-p mappings: {bodies}");
[StructLayout(LayoutKind.Sequential)]
struct stack_t
{
public nint ss_sp;
public int ss_flags;
public nuint ss_size;
}
static class Native
{
[DllImport("libc", SetLastError = true)] public static extern int sigaltstack(ref stack_t ss, out stack_t oss);
[DllImport("libc", SetLastError = true)] public static extern nint mmap(nint addr, nint length, int prot, int flags, int fd, nint offset);
[DllImport("libc", SetLastError = true)] public static extern int munmap(nint addr, nint length);
}
Expected behavior
When tearing down a thread pool thread, .NET runtime frees all memory allocated specifically for that thread.
Actual behavior
When tearing down a thread pool thread that has previously reconfigured the alternate signal stack, .NET runtime leaks a virtual memory allocation.
Actual behavior
Every exited thread leaves exactly two anonymous mappings behind, a 4 KB ---p guard page and a 12 KB rw-p region, which is the PAL's alternate stack (SIGSTKSZ + ALIGN_UP(sizeof(SignalHandlerWorkerReturnPoint), 16) + page, rounded up to 16 KB, with the first page mprotected as a guard).:
┌──────────┬─────────┬─────────────────────────┬────────────┬─────────────────────────────────────────────────┐
│ Mode │ Threads │ Mappings before → after │ Per thread │ 4 KB ---p / 12 KB rw-p anonymous mappings after │
├──────────┼─────────┼─────────────────────────┼────────────┼─────────────────────────────────────────────────┤
│ control │ 1,000 │ 247 → 260 │ 0.01 │ 17 / 2 │
├──────────┼─────────┼─────────────────────────┼────────────┼─────────────────────────────────────────────────┤
│ disable │ 1,000 │ 247 → 2,256 │ 2.01 │ 1,016 / 1,003 │
├──────────┼─────────┼─────────────────────────┼────────────┼─────────────────────────────────────────────────┤
│ dangling │ 1,000 │ 248 → 2,257 │ 2.01 │ 1,017 / 1,000 │
├──────────┼─────────┼─────────────────────────┼────────────┼─────────────────────────────────────────────────┤
│ disable │ 5,000 │ 248 → 10,264 │ 2.00 │ 5,017 / 5,002 │
└──────────┴─────────┴─────────────────────────┴────────────┴─────────────────────────────────────────────────┘
Regression?
the conditional free is the same on release/10.0 and main. Any earlier release with FreeSignalAlternateStack in this shape should behave the same.
Known Workarounds
Application developers can build their own thread pool.
Configuration
.NET 10.0.12, Ubuntu 24.04 on WSL2, x64
Other information
Root cause
src/coreclr/pal/src/thread/thread.cpp (identical on main and release/10.0):
void
CPalThread::FreeSignalAlternateStack()
{
#ifndef TARGET_WASM // WebAssembly does not support alternate signal stacks.
void *altstack = m_alternateStack;
m_alternateStack = nullptr;
if (altstack != nullptr)
{
stack_t ss, oss;
// The man page for sigaltstack says that when the ss.ss_flags is set to SS_DISABLE,
// all other ss fields are ignored. However, MUSL implementation checks that the
// ss_size is >= MINSIGSTKSZ even in this case.
ss.ss_size = MINSIGSTKSZ;
ss.ss_flags = SS_DISABLE;
ss.ss_sp = NULL;
int st = sigaltstack(&ss, &oss);
if ((st == 0) && (oss.ss_flags != SS_DISABLE))
{
// Make sure this altstack is this PAL's before freeing.
if (oss.ss_sp == altstack)
{
int st = munmap(oss.ss_sp, oss.ss_size);
_ASSERTE(st == 0);
}
}
}
#endif // !TARGET_WASM
}
The munmap runs only when the previously registered stack (oss) is enabled and is the PAL's own. Both ways a native library can leave the thread fail that test:
- it disabled its stack before exit (oss.ss_flags == SS_DISABLE), or
- it replaced the registration and left it in place, possibly pointing at memory it already unmapped (oss.ss_sp != altstack).
In either case m_alternateStack has already been cleared, so the mapping is unreachable and leaked. The equality check was presumably added so the PAL never unmaps someone else's stack, which is right, but it also stops the PAL from unmapping its own. The allocation site (EnsureSignalAlternateStack) keeps the pointer but not the size, and the size is only recoverable from oss when the registration is still the PAL's.
Note that the TLS-destructor ordering between the PAL and the other library is irrelevant: once a foreign stack has been registered on the thread, the PAL's stack is leaked whichever destructor runs first.
Metadata
Metadata
Labels
Type
Projects
- StatusShow more project fieldsNo status
Activity
dotnet-policy-service commentedon Nov 2, 2026
Tagging subscribers to this area: @JulieLeeMSFT, @VSadov
See info in area-owners.md if you want to be subscribed.
dotnet-policy-service commentedon Nov 2, 2026
Tagging subscribers to this area: @agocke
See info in area-owners.md if you want to be subscribed.
jkotas commentedon Nov 2, 2026
@janvorli Do you happen to remember what's the reason for this condition?
runtime/src/coreclr/pal/src/thread/thread.cpp
Line 2126 in f4d81ef
janvorli commentedon Nov 2, 2026
The condition was put in there in case another PAL allocated the alternate stack. I believe it is a debugger scenario, when managed code of the debugger (or SOS) has one PAL for the debugger side coreclr and one PAL for the mscordac/dbi.
Fix PAL alternate signal stack leak after application reconfiguration (…