Description
A .NET 11 process and a .NET 8/9/10 process that open the same named Mutex on Unix silently corrupt each other's owner/abandonment record: the .NET 11 side throws AbandonedMutexException on every acquisition that follows a normal release by the older side, while exclusion keeps working and nothing reports a version mismatch.
Since #117635, a named Mutex on Unix is implemented in managed code (src/libraries/System.Private.CoreLib/src/System/Threading/NamedMutex.Unix.cs, plus LowLevelCrossProcessMutex in src/native/libs/System.Native/pal_crossprocessmutex.c) instead of the CoreCLR PAL (src/coreclr/pal/src/synchobj/mutex.cpp).
Both implementations use the same shared-memory file (/tmp/.dotnet/shm/global/<name>, or the session/user-scoped variants), the same 8-byte header, SharedMemoryType.Mutex, and the same SyncSystemVersion = 1.
A .NET 11 process and a .NET ≤ 10 process that open a mutex of the same name therefore accept each other's file as valid and use it together.
The layout of the shared data behind the header changed, though:
The byte .NET 11 reads as IsAbandoned therefore lies inside the PAL's 64-bit m_lockOwnerThreadId.
The PAL writes all 0xFF into that field on every release, and .NET 11's TryAcquireLock consults IsAbandoned after every acquisition (L126–129):
if (IsAbandoned)
{
IsAbandoned = false;
result = MutexTryAcquireLockResult.AcquiredLockButMutexWasAbandoned;
}
So every time a .NET 11 process acquires a named mutex that a .NET ≤ 10 process last released, WaitOne throws AbandonedMutexException, although nothing was abandoned.
Nothing detects the mismatch: the file is page-aligned, the version byte is 1 on both sides, and exclusion itself keeps working because the lock sits at the same offset in both layouts.
The two processes do exclude each other correctly; only the owner/abandonment protocol is corrupted, which makes the failure easy to attribute to a crash in the other process instead.
The reverse direction is mostly benign (edit: maybe not so benign after review): the PAL detects abandonment through m_lockOwnerProcessId, which is at the same offset in both layouts, and its m_isAbandoned byte lies past the end of the .NET 11 struct, where nothing writes.
Reproduction Steps
The layout mismatch can be shown from the .NET 10 side alone by decoding the shared-memory file; the two-runtime steps at the end show the behavioural consequence.
NamedMutexLayout.csproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>disable</ImplicitUsings>
</PropertyGroup>
</Project>
Program.cs:
using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading;
var mode = args.Length > 0 ? args[0] : "";
var name = args.Length > 1 ? args[1] : "layout-repro";
var globalName = @"Global\" + name;
switch (mode)
{
case "hold": // acquire, wait for Enter, release, wait for Enter, exit
{
using var mutex = new Mutex(false, globalName);
Console.WriteLine($"[.NET {Environment.Version}] hold: acquired={mutex.WaitOne(0)} (pid {Environment.ProcessId}). Enter releases.");
Console.ReadLine();
mutex.ReleaseMutex();
Console.WriteLine($"[.NET {Environment.Version}] hold: released. Enter exits.");
Console.ReadLine();
return 0;
}
case "probe": // one non-blocking acquisition
{
using var mutex = new Mutex(false, globalName);
try
{
if (!mutex.WaitOne(0)) { Console.WriteLine($"[.NET {Environment.Version}] probe: HELD by someone else"); return 2; }
Console.WriteLine($"[.NET {Environment.Version}] probe: CLEAN acquisition");
}
catch (AbandonedMutexException)
{
Console.WriteLine($"[.NET {Environment.Version}] probe: ABANDONED - AbandonedMutexException");
}
mutex.ReleaseMutex();
return 0;
}
case "dump": // decode the shared data under both layouts
{
var bytes = File.ReadAllBytes(Path.Combine("/tmp/.dotnet/shm/global", name));
var data = bytes.AsSpan(8); // after the 8-byte header
Console.WriteLine($"shared data: {BitConverter.ToString(bytes, 8, 24).Replace('-', ' ')}");
// Linux: pthread_mutex_t first (40 bytes on glibc x86_64, 48 on aarch64); macOS: UINT32 timed-waiter count.
var pidOffset = OperatingSystem.IsMacOS() ? 4 : (RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? 48 : 40);
var tidOffset = pidOffset + 4;
var palTidOffset = (tidOffset + 7) / 8 * 8; // UINT64, 8-byte aligned
Console.WriteLine($"PAL (.NET <= 10) reads: pid @{pidOffset} = {U32(data, pidOffset):X8} tid(u64) @{palTidOffset} = {U64(data, palTidOffset):X16} isAbandoned @{palTidOffset + 8} = {data[palTidOffset + 8]}");
Console.WriteLine($".NET 11 reads: pid @{pidOffset} = {U32(data, pidOffset):X8} tid(u32) @{tidOffset} = {U32(data, tidOffset):X8} isAbandoned @{tidOffset + 4} = {data[tidOffset + 4]}");
return 0;
}
default:
Console.Error.WriteLine("usage: hold|probe|dump <name>");
return 1;
}
static uint U32(ReadOnlySpan<byte> d, int o) => BitConverter.ToUInt32(d.Slice(o, 4));
static ulong U64(ReadOnlySpan<byte> d, int o) => BitConverter.ToUInt64(d.Slice(o, 8));
Step 1 — what a .NET 10 process leaves in the file (no .NET 11 needed):
dotnet build -c Release
# terminal 1: hold the mutex on .NET 10
bin/Release/net10.0/NamedMutexLayout hold repro
# terminal 2: dump while held; then press Enter in terminal 1 (release) and dump again
bin/Release/net10.0/NamedMutexLayout dump repro
bin/Release/net10.0/NamedMutexLayout dump repro
Step 2 — the behavioural consequence, with both SDKs installed (add net11.0 to TargetFrameworks):
# terminal 1 (.NET 10): hold, press Enter once to release, keep the process alive
dotnet run -c Release -f net10.0 -- hold repro
# terminal 2 (.NET 11): acquire what the .NET 10 process just released
dotnet run -c Release -f net11.0 -- probe repro
Expected behavior
- Step 1: the field that .NET 11 will read as
IsAbandoned is 0 after a normal release by a .NET 10 process.
- Step 2:
probe: CLEAN acquisition — the mutex was released normally by a live process. (Running both sides on .NET 10, or both on .NET 11, does print CLEAN.)
Actual behavior
Step 1, macOS 26.6.2 arm64, .NET runtime 10.0.12:
# while held by the .NET 10 process
shared data: 00 00 00 00 7E B2 00 00 C4 00 32 00 00 00 00 00 00 00 00 00 00 00 00 00
PAL (.NET <= 10) reads: pid @4 = 0000B27E tid(u64) @8 = 00000000003200C4 isAbandoned @16 = 0
.NET 11 reads: pid @4 = 0000B27E tid(u32) @8 = 003200C4 isAbandoned @12 = 0
# after the .NET 10 process released it (file still open)
shared data: 00 00 00 00 FF FF FF FF FF FF FF FF FF FF FF FF 00 00 00 00 00 00 00 00
PAL (.NET <= 10) reads: pid @4 = FFFFFFFF tid(u64) @8 = FFFFFFFFFFFFFFFF isAbandoned @16 = 0
.NET 11 reads: pid @4 = FFFFFFFF tid(u32) @8 = FFFFFFFF isAbandoned @12 = 255
The upper half of the PAL's cleared 64-bit thread id is what .NET 11 reads as IsAbandoned.
On Linux the same happens at @48/@56: the low byte of the PAL's thread id becomes .NET 11's IsAbandoned, 0xFF after a release.
Step 2: probe: ABANDONED - AbandonedMutexException on the .NET 11 side, after every normal release by the .NET 10 side.
Regression?
Yes. Any two CoreCLR processes on .NET ≤ 10 (framework-dependent or self-contained, any mix of versions) share the PAL layout and interoperate.
.NET 11 is the first version whose named Mutex writes a different layout under the same SyncSystemVersion, so a .NET 11 process next to a .NET 8/9/10 process on the same machine no longer does.
(NativeAOT on Unix is a separate history: its named mutexes were per-process until .NET 11, #48720 / #110348, so there was nothing to be compatible with there.)
Known Workarounds
- Do not share a named mutex between a .NET 11 process and a .NET ≤ 10 process on Linux/macOS: use a different name per runtime major, or coordinate through something else (
flock on a file of your own, a Unix socket).
- Catch
AbandonedMutexException and ignore it when the other side is known to be a .NET ≤ 10 process. Fragile: real abandonment is then indistinguishable from the phantom one.
- What we did: carry our own port of the .NET 11 implementation with the PAL's layout (64-bit OS thread id,
isAbandoned after it) so that our NativeAOT .NET 10 build interoperates with our shipped CoreCLR builds (AT SOME COST!). That is code we would like to delete on .NET 11, which needs .NET 11's Mutex to read and write the PAL layout.
Configuration
Which version of .NET is the code running on?
The PAL side of the repro: .NET SDK 10.0.401, runtime 10.0.12.
The .NET 11 side: main as of today (NamedMutex.Unix.cs SharedData: uint _lockOwnerThreadId; byte _isAbandoned;, SyncSystemVersion = 1); exclusion between .NET 10 and .NET 11 RC1 processes was also verified to work, which is what hides this.
What OS and version, and what distro if applicable?
Reproduced on macOS 26.6.2 (build 25G83).
Linux (Ubuntu 22.04, glibc) has the same mismatch with the fields shifted by sizeof(pthread_mutex_t): PAL isAbandoned @56, .NET 11 IsAbandoned @48.
What is the architecture (x64, x86, ARM, ARM64)?
ARM64 for the captured output; the offsets on x64 are identical on macOS and differ on Linux only by sizeof(pthread_mutex_t) (40 vs 48).
Do you know whether it is specific to that configuration?
No: it is a property of the two struct definitions and applies to every Linux and macOS configuration. Windows is unaffected (kernel named mutexes, no shared-memory file).
Not using Blazor
Other information
Suggested fix, in order of preference:
- Keep the PAL layout in the managed implementation: a 64-bit owner thread id at the PAL's offset with
isAbandoned after it.
The managed side can still store its managed thread id in the low 32 bits; what matters for compatibility is where isAbandoned lives and what "unowned" looks like (InvalidSharedThreadId, all 0xFF, in the full 64 bits).
This keeps .NET 11 interoperable with every earlier CoreCLR, which is the property named mutexes have on Windows.
- Failing that, bump
SyncSystemVersion (or otherwise make the header identify the layout) so that mismatched implementations fail loudly at open time instead of silently misreading each other.
A .NET 11 process then cannot coordinate with a .NET 10 one at all, but that is strictly better than the current silent corruption of the abandonment protocol.
The repro's dump mode assumes glibc's sizeof(pthread_mutex_t) on Linux (40 on x86_64, 48 on aarch64); musl differs.
Related: #48720, #110348 (NativeAOT named mutexes per-process on Unix, fixed by #117635 — the change this issue is about).
Description
A .NET 11 process and a .NET 8/9/10 process that open the same named
Mutexon Unix silently corrupt each other's owner/abandonment record: the .NET 11 side throwsAbandonedMutexExceptionon every acquisition that follows a normal release by the older side, while exclusion keeps working and nothing reports a version mismatch.Since #117635, a named
Mutexon Unix is implemented in managed code (src/libraries/System.Private.CoreLib/src/System/Threading/NamedMutex.Unix.cs, plusLowLevelCrossProcessMutexinsrc/native/libs/System.Native/pal_crossprocessmutex.c) instead of the CoreCLR PAL (src/coreclr/pal/src/synchobj/mutex.cpp).Both implementations use the same shared-memory file (
/tmp/.dotnet/shm/global/<name>, or the session/user-scoped variants), the same 8-byte header,SharedMemoryType.Mutex, and the sameSyncSystemVersion = 1.A .NET 11 process and a .NET ≤ 10 process that open a mutex of the same name therefore accept each other's file as valid and use it together.
The layout of the shared data behind the header changed, though:
mutex.hpp@ release/10.0, L166–176)NamedMutex.Unix.csSharedData, L585–590;pal_crossprocessmutex.cLowLevelCrossProcessMutex)pthread_mutex_ton Linux,UINT32 m_timedWaiterCounton macOS)lockOwnerProcessIdUINT32uintlockOwnerThreadIdUINT64, the OS thread id (THREADSilentGetCurrentThreadId()),InvalidSharedThreadId(all0xFF) when unowneduint,Thread.CurrentThread.ManagedThreadId,InvalidThreadId = (uint)-1when unownedisAbandonedboolafter the 64-bit field: @16 (macOS), @56 (Linux x64)byteafter the 32-bit field: @12 (macOS), @48 (Linux x64)The byte .NET 11 reads as
IsAbandonedtherefore lies inside the PAL's 64-bitm_lockOwnerThreadId.The PAL writes all
0xFFinto that field on every release, and .NET 11'sTryAcquireLockconsultsIsAbandonedafter every acquisition (L126–129):So every time a .NET 11 process acquires a named mutex that a .NET ≤ 10 process last released,
WaitOnethrowsAbandonedMutexException, although nothing was abandoned.Nothing detects the mismatch: the file is page-aligned, the version byte is
1on both sides, and exclusion itself keeps working because the lock sits at the same offset in both layouts.The two processes do exclude each other correctly; only the owner/abandonment protocol is corrupted, which makes the failure easy to attribute to a crash in the other process instead.
The reverse direction is mostly benign (edit: maybe not so benign after review): the PAL detects abandonment through
m_lockOwnerProcessId, which is at the same offset in both layouts, and itsm_isAbandonedbyte lies past the end of the .NET 11 struct, where nothing writes.Reproduction Steps
The layout mismatch can be shown from the .NET 10 side alone by decoding the shared-memory file; the two-runtime steps at the end show the behavioural consequence.
NamedMutexLayout.csproj:Program.cs:Step 1 — what a .NET 10 process leaves in the file (no .NET 11 needed):
Step 2 — the behavioural consequence, with both SDKs installed (add
net11.0toTargetFrameworks):Expected behavior
IsAbandonedis0after a normal release by a .NET 10 process.probe: CLEAN acquisition— the mutex was released normally by a live process. (Running both sides on .NET 10, or both on .NET 11, does printCLEAN.)Actual behavior
Step 1, macOS 26.6.2 arm64, .NET runtime 10.0.12:
The upper half of the PAL's cleared 64-bit thread id is what .NET 11 reads as
IsAbandoned.On Linux the same happens at @48/@56: the low byte of the PAL's thread id becomes .NET 11's
IsAbandoned,0xFFafter a release.Step 2:
probe: ABANDONED - AbandonedMutexExceptionon the .NET 11 side, after every normal release by the .NET 10 side.Regression?
Yes. Any two CoreCLR processes on .NET ≤ 10 (framework-dependent or self-contained, any mix of versions) share the PAL layout and interoperate.
.NET 11 is the first version whose named
Mutexwrites a different layout under the sameSyncSystemVersion, so a .NET 11 process next to a .NET 8/9/10 process on the same machine no longer does.(NativeAOT on Unix is a separate history: its named mutexes were per-process until .NET 11, #48720 / #110348, so there was nothing to be compatible with there.)
Known Workarounds
flockon a file of your own, a Unix socket).AbandonedMutexExceptionand ignore it when the other side is known to be a .NET ≤ 10 process. Fragile: real abandonment is then indistinguishable from the phantom one.isAbandonedafter it) so that our NativeAOT .NET 10 build interoperates with our shipped CoreCLR builds (AT SOME COST!). That is code we would like to delete on .NET 11, which needs .NET 11'sMutexto read and write the PAL layout.Configuration
Which version of .NET is the code running on?
The PAL side of the repro: .NET SDK 10.0.401, runtime 10.0.12.
The .NET 11 side:
mainas of today (NamedMutex.Unix.csSharedData:uint _lockOwnerThreadId; byte _isAbandoned;,SyncSystemVersion = 1); exclusion between .NET 10 and .NET 11 RC1 processes was also verified to work, which is what hides this.What OS and version, and what distro if applicable?
Reproduced on macOS 26.6.2 (build 25G83).
Linux (Ubuntu 22.04, glibc) has the same mismatch with the fields shifted by
sizeof(pthread_mutex_t): PALisAbandoned@56, .NET 11IsAbandoned@48.What is the architecture (x64, x86, ARM, ARM64)?
ARM64 for the captured output; the offsets on x64 are identical on macOS and differ on Linux only by
sizeof(pthread_mutex_t)(40 vs 48).Do you know whether it is specific to that configuration?
No: it is a property of the two struct definitions and applies to every Linux and macOS configuration. Windows is unaffected (kernel named mutexes, no shared-memory file).
Not using Blazor
Other information
Suggested fix, in order of preference:
isAbandonedafter it.The managed side can still store its managed thread id in the low 32 bits; what matters for compatibility is where
isAbandonedlives and what "unowned" looks like (InvalidSharedThreadId, all0xFF, in the full 64 bits).This keeps .NET 11 interoperable with every earlier CoreCLR, which is the property named mutexes have on Windows.
SyncSystemVersion(or otherwise make the header identify the layout) so that mismatched implementations fail loudly at open time instead of silently misreading each other.A .NET 11 process then cannot coordinate with a .NET 10 one at all, but that is strictly better than the current silent corruption of the abandonment protocol.
The repro's
dumpmode assumes glibc'ssizeof(pthread_mutex_t)on Linux (40 on x86_64, 48 on aarch64); musl differs.Related: #48720, #110348 (NativeAOT named mutexes per-process on Unix, fixed by #117635 — the change this issue is about).