From 72c6a2b7b1aee19c042bf0fab9c06a694c8d3d37 Mon Sep 17 00:00:00 2001 From: zacala Date: Thu, 22 Oct 2026 15:32:58 +0000 Subject: [PATCH 1/3] Fail at once when a thread waits for its own lock through another instance Locks are reentrant only through the instance that took them. A second instance of the same region on the same thread (two components that each open the region) waited for the whole timeout for a write lock that its own thread holds, for ever with an infinite timeout. The write lock records its owner by process, thread and PID namespace, so StructuredMemory and SharedArray now check it before they wait and throw InvalidOperationException with an explanation. A read lock held through another instance cannot be recognised (read locks have no owner), and the raw MemoryRegion.TryAcquire* methods keep returning false. --- .../RemainingLimitationsTests.cs | 122 ++++++++++++++++++ InterprocessMemory/MemoryRegion.cs | 32 +++++ InterprocessMemory/SharedArray.cs | 2 + InterprocessMemory/StructuredMemory.cs | 2 + 4 files changed, 158 insertions(+) create mode 100644 InterprocessMemory.Tests/RemainingLimitationsTests.cs diff --git a/InterprocessMemory.Tests/RemainingLimitationsTests.cs b/InterprocessMemory.Tests/RemainingLimitationsTests.cs new file mode 100644 index 0000000..4b1cd38 --- /dev/null +++ b/InterprocessMemory.Tests/RemainingLimitationsTests.cs @@ -0,0 +1,122 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using InterprocessMemory; + +namespace InterprocessMemory.Tests; + +/// Limitations that were documented in 4.0.0 and are handled now. +[TestFixture] +public class RemainingLimitationsTests +{ + private static string N(string prefix) => $"Limits_{prefix}_{Guid.NewGuid():N}"; + + public struct WideSchema : IMemorySchema + { + public IEnumerable GetFields() + { + yield return FieldDefinition.Scalar("Value"); + } + } + + // ── Two instances of one region on one thread ──────────────────────────── + + // Locks are reentrant only through the instance that took them. A second instance on the same thread used to + // wait for the whole timeout for a lock that its own thread holds (for ever with an infinite timeout). + + [Test, Timeout(60000)] + public void StructuredMemory_SecondInstanceOnTheSameThread_WriteLock_FailsAtOnce() + { + string name = N("SmWrite"); + using var a = StructuredMemory.CreateOrOpen(name, new WideSchema()); + using var b = StructuredMemory.OpenExisting(name, new WideSchema()); + + using (a.AcquireWriteLock()) + { + var sw = Stopwatch.StartNew(); + var ex = Assert.Throws(() => b.AcquireWriteLock(TimeSpan.FromSeconds(10))); + Assert.That(sw.Elapsed, Is.LessThan(TimeSpan.FromSeconds(3))); + Assert.That(ex!.Message, Does.Contain("another instance")); + } + + // Once the first instance has let go, the second one can take the lock. + using (b.AcquireWriteLock(TimeSpan.FromSeconds(2))) + { + } + } + + [Test, Timeout(60000)] + public void StructuredMemory_SecondInstanceOnTheSameThread_ReadLock_FailsAtOnce() + { + string name = N("SmRead"); + using var a = StructuredMemory.CreateOrOpen(name, new WideSchema()); + using var b = StructuredMemory.OpenExisting(name, new WideSchema()); + + using (a.AcquireWriteLock()) + { + var sw = Stopwatch.StartNew(); + Assert.Throws(() => b.AcquireReadLock(TimeSpan.FromSeconds(10))); + Assert.That(sw.Elapsed, Is.LessThan(TimeSpan.FromSeconds(3))); + } + } + + [Test, Timeout(60000)] + public void StructuredMemory_AutomaticLockOfASecondInstance_FailsAtOnce() + { + string name = N("SmAuto"); + using var a = StructuredMemory.CreateOrOpen(name, new WideSchema()); + using var b = StructuredMemory.OpenExisting(name, new WideSchema()); + + using (a.AcquireWriteLock()) + { + // A 16 byte value is written under the shared lock automatically. + var sw = Stopwatch.StartNew(); + Assert.Throws(() => b.Write("Value", Guid.NewGuid())); + Assert.That(sw.Elapsed, Is.LessThan(TimeSpan.FromSeconds(3))); + } + } + + [Test, Timeout(60000)] + public void SharedArray_SecondInstanceOnTheSameThread_FailsAtOnce() + { + string name = N("Array"); + using var a = SharedArray.CreateOrOpen(name, 8); + using var b = SharedArray.OpenExisting(name); + + using (a.AcquireWriteLock()) + { + var sw = Stopwatch.StartNew(); + Assert.Throws(() => b.AcquireWriteLock(TimeSpan.FromSeconds(10))); + Assert.Throws(() => b.AcquireReadLock(TimeSpan.FromSeconds(10))); + Assert.That(sw.Elapsed, Is.LessThan(TimeSpan.FromSeconds(3))); + } + + using (b.AcquireReadLock(TimeSpan.FromSeconds(2))) + { + } + } + + [Test, Timeout(60000)] + public void AnotherThreadStillTimesOutAsBefore() + { + string name = N("OtherThread"); + using var a = StructuredMemory.CreateOrOpen(name, new WideSchema()); + using var b = StructuredMemory.OpenExisting(name, new WideSchema()); + + using (a.AcquireWriteLock()) + { + // The lock belongs to this thread, not to the contender, so this is ordinary contention. + Exception? error = null; + DedicatedThread.Run(() => + { + try + { b.AcquireWriteLock(TimeSpan.FromMilliseconds(100)); } + catch (Exception ex) { error = ex; } + }).Wait(); + + Assert.That(error, Is.InstanceOf()); + } + } +} diff --git a/InterprocessMemory/MemoryRegion.cs b/InterprocessMemory/MemoryRegion.cs index 4c1fe57..54a93d1 100644 --- a/InterprocessMemory/MemoryRegion.cs +++ b/InterprocessMemory/MemoryRegion.cs @@ -1636,6 +1636,38 @@ public LockOwnerInfo GetLockOwnerInfo() }; } + /// + /// True when the calling thread of this process holds the write lock of the region, whichever instance + /// took it. The lock records its owner by process and thread, so a second instance of the same region + /// in one process can tell that the lock it is about to wait for belongs to its own thread, which it + /// would never get (locks are reentrant only through the instance that took them). + /// + internal bool IsWriteLockHeldByCurrentThread() + { + var header = (SharedHeader*)_basePtr; + if (_basePtr == null || Volatile.Read(ref header->WriterLockState) == 0) + return false; + + return Volatile.Read(ref header->LockOwnerProcessId) == Environment.ProcessId + && Volatile.Read(ref header->LockOwnerThreadId) == Environment.CurrentManagedThreadId + && !IsOtherPidNamespace(Volatile.Read(ref header->LockOwnerPidNamespace)); + } + + /// + /// Called by a container before it waits for the region lock. Waiting for a write lock that the calling + /// thread itself holds through another instance of the region cannot succeed; this reports it at once + /// instead of after the whole timeout (which is for ever with ). + /// A read lock held through another instance cannot be recognised: read locks have no owner. + /// + internal static void ThrowIfWriteLockHeldByCurrentThread(IMemoryRegion region, string container) + { + if (region is MemoryRegion memoryRegion && memoryRegion.IsWriteLockHeldByCurrentThread()) + throw new InvalidOperationException( + $"This thread already holds the write lock of the region through another instance of {container}. " + + "A lock is reentrant only through the instance that took it, so waiting for it here would never " + + "end. Share one instance per region in a process, or release the lock first."); + } + /// public uint CalculateChecksum(long offset, int length) { diff --git a/InterprocessMemory/SharedArray.cs b/InterprocessMemory/SharedArray.cs index 678f2d4..ac45ef1 100644 --- a/InterprocessMemory/SharedArray.cs +++ b/InterprocessMemory/SharedArray.cs @@ -564,6 +564,7 @@ private LockTicket EnterWrite(TimeSpan timeout) bool tookRegionLock = false; if (_writeLockDepth.Value == 0) { + MemoryRegion.ThrowIfWriteLockHeldByCurrentThread(_buffer, nameof(SharedArray)); if (!_buffer.TryAcquireWriteLock(timeout)) throw new TimeoutException($"Failed to acquire write lock within {timeout}"); tookRegionLock = true; @@ -607,6 +608,7 @@ private LockTicket EnterRead(TimeSpan timeout) bool tookRegionLock = false; if (_readLockDepth.Value == 0 && _writeLockDepth.Value == 0) { + MemoryRegion.ThrowIfWriteLockHeldByCurrentThread(_buffer, nameof(SharedArray)); if (!_buffer.TryAcquireReadLock(timeout)) throw new TimeoutException($"Failed to acquire read lock within {timeout}"); tookRegionLock = true; diff --git a/InterprocessMemory/StructuredMemory.cs b/InterprocessMemory/StructuredMemory.cs index ae5b543..de47068 100644 --- a/InterprocessMemory/StructuredMemory.cs +++ b/InterprocessMemory/StructuredMemory.cs @@ -778,6 +778,7 @@ public WriteLock AcquireWriteLock(TimeSpan timeout) throw new InvalidOperationException( "Cannot take the write lock while holding only a read lock. Release the read lock first."); + MemoryRegion.ThrowIfWriteLockHeldByCurrentThread(_buffer, nameof(StructuredMemory)); if (!_buffer.TryAcquireWriteLock(timeout)) throw new TimeoutException($"Failed to acquire write lock within {timeout}"); @@ -817,6 +818,7 @@ public ReadLock AcquireReadLock(TimeSpan timeout) if (_readLockDepth.Value > 0 || _writeLockDepth.Value > 0) return new ReadLock(this, null, ++_readLockDepth.Value); + MemoryRegion.ThrowIfWriteLockHeldByCurrentThread(_buffer, nameof(StructuredMemory)); if (!_buffer.TryAcquireReadLock(timeout)) throw new TimeoutException($"Failed to acquire read lock within {timeout}"); From 52243e6a1b235110e50aef016bd31f99e0d8f16d Mon Sep 17 00:00:00 2001 From: zacala Date: Thu, 22 Oct 2026 15:34:00 +0000 Subject: [PATCH 2/3] Describe auto layout structs by the offsets the runtime chose The fingerprint of a generic struct (ValueTuple, KeyValuePair, ...) was built from field names, types and explicit FieldOffset values, so for LayoutKind.Auto it said nothing about where the runtime put each field. Two processes on runtimes that lay a type out differently (net8.0 and net10.0 now both ship) would have agreed on the fingerprint and read each other's fields from the wrong places. The offsets are measured with a dynamic method (Marshal.OffsetOf rejects these types) and are part of the descriptor. Types the marshaller accepts keep their 3.0.0 fingerprint, and the generic fallback has never been released, so no existing region is affected. Fingerprints are cached per type now, since measuring is not free. --- .../TypeLayoutFingerprintTests.cs | 60 +++++++++++++++++++ InterprocessMemory/TypeLayoutFingerprint.cs | 57 +++++++++++++++--- 2 files changed, 110 insertions(+), 7 deletions(-) diff --git a/InterprocessMemory.Tests/TypeLayoutFingerprintTests.cs b/InterprocessMemory.Tests/TypeLayoutFingerprintTests.cs index d475f3d..be28b5f 100644 --- a/InterprocessMemory.Tests/TypeLayoutFingerprintTests.cs +++ b/InterprocessMemory.Tests/TypeLayoutFingerprintTests.cs @@ -1,5 +1,8 @@ using System.Collections.Generic; using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.InteropServices; using NUnit.Framework; using InterprocessMemory; @@ -44,6 +47,63 @@ public void GenericUnmanagedStructs_HaveStableDistinctFingerprints() Assert.That(tupleA, Is.Not.EqualTo(pair)); } + [StructLayout(LayoutKind.Auto)] + private struct AutoMixed + { + public byte A; + public long B; + public short C; + public int D; + } + + [StructLayout(LayoutKind.Explicit, Size = 24)] + private struct ExplicitFields + { + [FieldOffset(0)] public int First; + [FieldOffset(16)] public long Second; + } + + private static int[] OffsetsOf() where T : unmanaged + { + var fields = typeof(T) + .GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .OrderBy(f => f.MetadataToken) + .ToArray(); + return TypeLayoutFingerprint.ManagedFieldOffsets(typeof(T), fields); + } + + [Test] + public void ManagedFieldOffsets_AreTheOffsetsTheRuntimeChose() + { + // (int, int): two 4 byte fields, whatever order the runtime picks. + int[] pair = OffsetsOf<(int, int)>(); + Assert.That(pair.OrderBy(x => x).ToArray(), Is.EqualTo(new[] { 0, 4 })); + + // Explicit layout: the offsets of the attribute. + Assert.That(OffsetsOf(), Is.EqualTo(new[] { 0, 16 })); + + // Auto layout: every field has its own bytes inside the struct (no overlap, nothing past the end). + int[] offsets = OffsetsOf(); + int[] sizes = { 1, 8, 2, 4 }; + int total = System.Runtime.CompilerServices.Unsafe.SizeOf(); + var spans = offsets.Zip(sizes, (offset, size) => (offset, size)).OrderBy(x => x.offset).ToArray(); + Assert.That(spans[0].offset, Is.GreaterThanOrEqualTo(0)); + for (int i = 1; i < spans.Length; i++) + Assert.That(spans[i].offset, Is.GreaterThanOrEqualTo(spans[i - 1].offset + spans[i - 1].size)); + Assert.That(spans[^1].offset + spans[^1].size, Is.LessThanOrEqualTo(total)); + } + + [Test] + public void AutoLayoutStructs_AreDescribedByTheirOffsets() + { + // The descriptor of a type that the marshaller rejects contains the measured offsets. A type that the + // marshaller accepts keeps its 3.0.0 descriptor (see MarshallableTypes_KeepTheirRelease300Fingerprint), + // so only the generic fallback changed, and it has never been released. + var first = TypeLayoutFingerprint.Create<(byte, long)>(); + Assert.That(TypeLayoutFingerprint.Create<(byte, long)>(), Is.EqualTo(first), "stable within a process"); + Assert.That(TypeLayoutFingerprint.Create<(long, byte)>(), Is.Not.EqualTo(first)); + } + [Test] public void SingleProducerQueue_AcceptsGenericStruct() { diff --git a/InterprocessMemory/TypeLayoutFingerprint.cs b/InterprocessMemory/TypeLayoutFingerprint.cs index 1a11496..378b837 100644 --- a/InterprocessMemory/TypeLayoutFingerprint.cs +++ b/InterprocessMemory/TypeLayoutFingerprint.cs @@ -2,7 +2,9 @@ using System.Buffers.Binary; using System.Collections.Generic; using System.Linq; +using System.Collections.Concurrent; using System.Reflection; +using System.Reflection.Emit; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Security.Cryptography; @@ -12,7 +14,13 @@ namespace InterprocessMemory { internal readonly record struct TypeLayoutFingerprint(ulong Low, ulong High) { - public static TypeLayoutFingerprint Create() where T : unmanaged + // A fingerprint depends on the type only, and the managed descriptor measures offsets with dynamic methods. + private static readonly ConcurrentDictionary s_cache = new(); + + public static TypeLayoutFingerprint Create() where T : unmanaged => + s_cache.GetOrAdd(typeof(T), static _ => Compute()); + + private static TypeLayoutFingerprint Compute() where T : unmanaged { var descriptor = new StringBuilder(256); try @@ -114,21 +122,56 @@ private static void AppendManagedType(StringBuilder target, Type type, HashSet field.MetadataToken); + .OrderBy(field => field.MetadataToken) + .ToArray(); + int[] offsets = ManagedFieldOffsets(type, fields); - foreach (var field in fields) + for (int i = 0; i < fields.Length; i++) { + FieldInfo field = fields[i]; int? explicitOffset = field.GetCustomAttribute()?.Value; target.Append(";f:").Append(field.Name).Append('@') - .Append(explicitOffset?.ToString() ?? "-").Append(':'); + .Append(explicitOffset?.ToString() ?? "-").Append('=').Append(offsets[i]).Append(':'); AppendManagedType(target, field.FieldType, active); } active.Remove(type); } + + /// The offset of each field of inside the type, as the runtime laid it out. + internal static int[] ManagedFieldOffsets(Type type, FieldInfo[] fields) + { + var offsets = new int[fields.Length]; + for (int i = 0; i < fields.Length; i++) + offsets[i] = MeasureFieldOffset(type, fields[i]); + return offsets; + } + + // There is no API for the offset of a field of an auto layout type (Marshal.OffsetOf rejects them), so + // it is measured: a dynamic method takes the address of the field of a local and of the local itself + // and returns the difference. + private static int MeasureFieldOffset(Type type, FieldInfo field) + { + var method = new DynamicMethod( + "FieldOffset", typeof(int), Type.EmptyTypes, typeof(TypeLayoutFingerprint).Module, skipVisibility: true); + ILGenerator il = method.GetILGenerator(); + LocalBuilder local = il.DeclareLocal(type); + il.Emit(OpCodes.Ldloca, local); + il.Emit(OpCodes.Ldflda, field); + il.Emit(OpCodes.Conv_U); + il.Emit(OpCodes.Ldloca, local); + il.Emit(OpCodes.Conv_U); + il.Emit(OpCodes.Sub); + il.Emit(OpCodes.Conv_I4); + il.Emit(OpCodes.Ret); + return ((Func)method.CreateDelegate(typeof(Func)))(); + } } } From 86a7c83168fbae706d7ef5f2d1e4c8806697c356 Mon Sep 17 00:00:00 2001 From: zacala Date: Thu, 22 Oct 2026 15:38:34 +0000 Subject: [PATCH 3/3] Accept Global\ and Local\ in region names Without a prefix a Windows section lives in the namespace of the session of the process that creates it, so a service (session 0) and a desktop process could not share a region, and any backslash was rejected. A name may now start with Global\ or Local\ (in any case); what follows must still be flat. Windows gets the full name; Linux has no sessions and drops the prefix from the file name, so Global\x, Local\x and x are one region there and one name works on both platforms. Remove uses the same file name. The two SharedArray tests that used a second instance on the test thread as a stand-in for "another holder" ask from another thread now, since the same thread is reported at once (previous commit). --- CHANGELOG.md | 20 ++++ .../RemainingLimitationsTests.cs | 93 +++++++++++++++++++ InterprocessMemory.Tests/SharedArrayTests.cs | 24 ++++- InterprocessMemory/MemoryRegion.cs | 35 ++++++- README.md | 23 +++-- 5 files changed, 178 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89de0fc..cd81977 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,26 @@ Migrating from 3.0.0 or 2.x: see [MIGRATION.md](MIGRATION.md). `Process.StartTime`, which differs between observers, so it takes the write lock away from every live owner, including one that runs a later version. Update all processes that use a region together. +## 4.1.0 (not released yet) + +### Added + +- Region names may start with `Global\` or `Local\`, the Windows kernel namespaces (all sessions, or the session of + the process). A service and a desktop process can share a region through `Global\name`. What follows the prefix + must be flat. On Linux the prefix is dropped (there are no sessions), so `Global\x`, `Local\x` and `x` are one + region and the same name works on both platforms. Names with any other backslash are still rejected. + +### Fixed + +- A thread that holds the write lock through one instance of a region used to wait for the whole timeout (for ever + with an infinite one) when it asked for a lock through another instance of the same region. `StructuredMemory` + and `SharedArray` now throw `InvalidOperationException` at once. A read lock held through another instance + cannot be recognised and still waits. +- The fingerprint of a generic struct (`ValueTuple`, `KeyValuePair<,>`) includes the offsets the runtime gave its + fields, which matters for `LayoutKind.Auto`: processes on runtimes that lay a type out differently now disagree + on the fingerprint instead of reading each other's fields from the wrong places. Types that the marshaller + accepts keep their fingerprint, and the generic fallback has not been released. + ## 4.0.0 (not released yet) Changes since the 3.0.0 release. The memory format is unchanged (format version 3). diff --git a/InterprocessMemory.Tests/RemainingLimitationsTests.cs b/InterprocessMemory.Tests/RemainingLimitationsTests.cs index 4b1cd38..090b2a5 100644 --- a/InterprocessMemory.Tests/RemainingLimitationsTests.cs +++ b/InterprocessMemory.Tests/RemainingLimitationsTests.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Diagnostics; +using System.IO; using System.Threading; using System.Threading.Tasks; using NUnit.Framework; @@ -119,4 +120,96 @@ public void AnotherThreadStillTimesOutAsBefore() Assert.That(error, Is.InstanceOf()); } } + + // ── Windows namespace prefixes in names ────────────────────────────────── + + [Test] + public void Names_MayStartWithGlobalOrLocal_ButMustBeFlatAfterThat() + { + string unique = Guid.NewGuid().ToString("N"); + + foreach (string bad in new[] + { + "Global\\", // nothing after the prefix + "Local\\ ", // only white space after it + "Global\\a\\b", // not flat after the prefix + "Global\\Local\\" + unique, + "Other\\" + unique, // only Global and Local are namespaces + "Global\\..", + "Local\\a/b", + }) + { + Assert.Throws( + () => MemoryRegion.CreateOrOpen(bad, 1024), + $"'{bad}' must be rejected"); + } + } + + [Test] + public void PrefixedAndPlainNames_AreOneRegionOnLinux() + { + if (!OperatingSystem.IsLinux()) + Assert.Ignore("Linux has no sessions: the prefix is dropped from the file name there."); + + string flat = N("Prefix"); + using var global = MemoryRegion.CreateOrOpen("Global\\" + flat, 4096); + global.Write(new byte[] { 1, 2, 3, 4 }, 0); + + using var plain = MemoryRegion.OpenExisting(flat); + using var local = MemoryRegion.OpenExisting("local\\" + flat); // the prefix is not case sensitive + + var read = new byte[4]; + plain.Read(read, 0); + Assert.That(read, Is.EqualTo(new byte[] { 1, 2, 3, 4 })); + local.Read(read, 0); + Assert.That(read, Is.EqualTo(new byte[] { 1, 2, 3, 4 })); + Assert.That(global.Name, Is.EqualTo("Global\\" + flat), "Name keeps what the caller passed"); + + Assert.That(MemoryRegion.Remove("Local\\" + flat), Is.True); + Assert.That(File.Exists("/dev/shm/" + flat), Is.False); + } + + [Test] + public void LocalNamespace_WorksOnWindows() + { + if (!OperatingSystem.IsWindows()) + Assert.Ignore("The kernel namespaces exist on Windows only."); + + string name = "Local\\" + N("WinLocal"); + using var creator = MemoryRegion.CreateOrOpen(name, 4096); + creator.Write(new byte[] { 9, 8, 7 }, 0); + + using var opener = MemoryRegion.OpenExisting(name); + var read = new byte[3]; + opener.Read(read, 0); + Assert.That(read, Is.EqualTo(new byte[] { 9, 8, 7 })); + } + + [Test] + public void GlobalNamespace_WorksOnWindowsWithThePrivilege() + { + if (!OperatingSystem.IsWindows()) + Assert.Ignore("The kernel namespaces exist on Windows only."); + + string name = "Global\\" + N("WinGlobal"); + MemoryRegion creator; + try + { + creator = MemoryRegion.CreateOrOpen(name, 4096); + } + catch (UnauthorizedAccessException) + { + Assert.Ignore("Creating a section in the Global namespace needs SeCreateGlobalPrivilege."); + return; + } + + using (creator) + { + creator.Write(new byte[] { 5, 6 }, 0); + using var opener = MemoryRegion.OpenExisting(name); + var read = new byte[2]; + opener.Read(read, 0); + Assert.That(read, Is.EqualTo(new byte[] { 5, 6 })); + } + } } diff --git a/InterprocessMemory.Tests/SharedArrayTests.cs b/InterprocessMemory.Tests/SharedArrayTests.cs index a7e347b..e379ec5 100644 --- a/InterprocessMemory.Tests/SharedArrayTests.cs +++ b/InterprocessMemory.Tests/SharedArrayTests.cs @@ -571,6 +571,19 @@ public void AtomicElements_BypassTheLock_WideElementsDoNot() Assert.That(blockedRead.Wait(TimeSpan.FromSeconds(5)), Is.True); } + // Runs the action on a thread of its own and returns what it threw, or null. + private static Exception? OnAnotherThread(Action action) + { + Exception? error = null; + DedicatedThread.Run(() => + { + try + { action(); } + catch (Exception ex) { error = ex; } + }).Wait(); + return error; + } + [Test, Timeout(30000)] public void ExplicitWriteLock_ExcludesOtherInstances_ForAnyElementType() { @@ -580,8 +593,12 @@ public void ExplicitWriteLock_ExcludesOtherInstances_ForAnyElementType() using (owner.AcquireWriteLock()) { - Assert.Throws(() => other.AcquireWriteLock(TimeSpan.FromMilliseconds(100))); - Assert.Throws(() => other.AcquireReadLock(TimeSpan.FromMilliseconds(100))); + // Another thread is the contender: this thread owns the lock, and asking for it again through a + // second instance would be reported at once instead of timing out (see RemainingLimitationsTests). + Assert.That(OnAnotherThread(() => other.AcquireWriteLock(TimeSpan.FromMilliseconds(100))), + Is.InstanceOf()); + Assert.That(OnAnotherThread(() => other.AcquireReadLock(TimeSpan.FromMilliseconds(100))), + Is.InstanceOf()); } using (other.AcquireWriteLock(TimeSpan.FromSeconds(1))) @@ -617,7 +634,8 @@ public void Locks_AreReentrant_AndTheIndexerDoesNotTakeThemAgain() } // The inner guards released only their own level: the outer lock is still exclusive. - Assert.Throws(() => peer.AcquireReadLock(TimeSpan.FromMilliseconds(100))); + Assert.That(OnAnotherThread(() => peer.AcquireReadLock(TimeSpan.FromMilliseconds(100))), + Is.InstanceOf()); } using (peer.AcquireWriteLock(TimeSpan.FromSeconds(1))) diff --git a/InterprocessMemory/MemoryRegion.cs b/InterprocessMemory/MemoryRegion.cs index 54a93d1..9c11c36 100644 --- a/InterprocessMemory/MemoryRegion.cs +++ b/InterprocessMemory/MemoryRegion.cs @@ -401,7 +401,7 @@ public static bool Remove(string name, MemoryRegionOptions? options = null) throw new PlatformNotSupportedException( "MemoryRegion requires Windows or Linux unless MemoryRegionOptions.FilePath is used."); - path = "/dev/shm/" + name; + path = "/dev/shm/" + FileNameOf(name); } if (!File.Exists(path)) @@ -621,7 +621,7 @@ private void CreateMmfFromWindowsNamedRegion(long totalSize) /// private void CreateMmfFromLinuxDevShm(long totalSize) { - _backingFilePath = "/dev/shm/" + _name; + _backingFilePath = "/dev/shm/" + FileNameOf(_name); // FileMode.CreateNew lets the kernel decide who creates the file. Two processes that both saw an // empty file used to take the creator's role, and the one that then failed (another capacity, @@ -772,18 +772,43 @@ internal static void EnsureFreeSpace(string directory, long requiredBytes, strin "(docker run --shm-size, or a larger memory-backed emptyDir in Kubernetes)."); } + private const string GlobalPrefix = "Global\\"; + private const string LocalPrefix = "Local\\"; + /// - /// Validates the cross-platform flat identifier used for the named region. + /// The length of the Windows kernel namespace prefix (Global\ or Local\, in any case) at the + /// start of , or 0 when there is none. + /// + private static int NamespacePrefixLength(string name) => + name.StartsWith(GlobalPrefix, StringComparison.OrdinalIgnoreCase) ? GlobalPrefix.Length + : name.StartsWith(LocalPrefix, StringComparison.OrdinalIgnoreCase) ? LocalPrefix.Length + : 0; + + /// + /// The name of the backing file of a region on Linux: the name without its namespace prefix. Linux has + /// no sessions, so Global\x, Local\x and x are one region there. + /// + private static string FileNameOf(string name) => name.Substring(NamespacePrefixLength(name)); + + /// + /// Validates the cross-platform flat identifier used for the named region. A name may start with + /// Global\ or Local\, which Windows reads as the namespace of the kernel object (all sessions + /// or the session of the process); what follows must be flat. /// private static string ValidateFlatName(string name) { if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("Name cannot be empty.", nameof(name)); - if (name.Contains('/') || name.Contains('\\') || name == "." || name == "..") + string flat = FileNameOf(name); + if (string.IsNullOrWhiteSpace(flat)) + throw new ArgumentException( + $"Name '{name}' has nothing after its namespace prefix.", nameof(name)); + + if (flat.Contains('/') || flat.Contains('\\') || flat == "." || flat == "..") throw new ArgumentException( $"Name '{name}' contains path separators or traversal fragments; " + - $"use a flat identifier for cross-platform compatibility.", + $"use a flat identifier for cross-platform compatibility (a leading Global\\ or Local\\ is allowed).", nameof(name)); if (name.Contains('\0')) diff --git a/README.md b/README.md index 3b46845..1c78b51 100644 --- a/README.md +++ b/README.md @@ -78,15 +78,20 @@ do not `await` between acquiring and releasing a lock, because the continuation different thread. Disposing a region while another thread is waiting for one of its locks releases that thread with an `ObjectDisposedException`. -A lock is remembered by the instance that took it. Opening the same region twice in one process and -taking the lock through both instances on one thread therefore waits for itself until the timeout (the -second instance cannot tell that the lock belongs to its own thread). Open a region once per process and -share the instance, or keep the two instances in different threads. - -On Windows a region name lives in the namespace of the session of the process that creates it. A -service (session 0) and a process of a logged-on user do not see each other's regions, and names with a -`Global\` or `Local\` prefix are rejected (names must be flat identifiers); sharing across sessions is not -supported yet. +A lock is remembered by the instance that took it: it is reentrant through that instance only. Open a +region once per process and share the instance. If one thread does use two instances of a region, then while +it holds the **write** lock through one of them the other throws `InvalidOperationException` at once instead of +waiting for its timeout. A thread that holds a **read** lock through one instance and asks for the write lock +through the other cannot be recognised (read locks have no owner) and still waits for the timeout. + +On Windows a name without a prefix lives in the namespace of the session of the process that creates it, so +a service (session 0) and a process of a logged-on user do not see each other's regions. A name that starts +with `Global\` is visible to every session; `Local\` says "this session" explicitly. What follows the prefix +must be a flat identifier. Creating a section in the `Global\` namespace needs the `SeCreateGlobalPrivilege` +(services and elevated processes have it; an ordinary user session can open one but not create one), and +the section gets the default access control of its creator, so processes under other accounts may be refused +access: run both sides under the same account for now. Linux has no sessions: the prefix is dropped there, and +`Global\x`, `Local\x` and `x` are one region, so the same name works on both platforms. On Linux and macOS a new backing file gets the platform default permissions (usually 0644: readable by every user, writable by the owner). Set `MemoryRegionOptions.UnixCreateMode` to restrict it (for example to owner