Skip to content

.NET 11 breaks named Mutex interop with every earlier .NET on Linux and macOS #134491

Description

@danielo-unity3d

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:

field .NET ≤ 10 PAL (mutex.hpp @ release/10.0, L166–176) .NET 11 managed (NamedMutex.Unix.cs SharedData, L585–590; pal_crossprocessmutex.c LowLevelCrossProcessMutex)
lock (pthread_mutex_t on Linux, UINT32 m_timedWaiterCount on macOS) same same
lockOwnerProcessId UINT32 uint
lockOwnerThreadId UINT64, the OS thread id (THREADSilentGetCurrentThreadId()), InvalidSharedThreadId (all 0xFF) when unowned uint, Thread.CurrentThread.ManagedThreadId, InvalidThreadId = (uint)-1 when unowned
isAbandoned bool after the 64-bit field: @16 (macOS), @56 (Linux x64) byte after the 32-bit field: @12 (macOS), @48 (Linux x64)

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:

  1. 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.
  2. 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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions