diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a9fe82..e615165 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 - 2026-10-22 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 new file mode 100644 index 0000000..090b2a5 --- /dev/null +++ b/InterprocessMemory.Tests/RemainingLimitationsTests.cs @@ -0,0 +1,215 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +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()); + } + } + + // ── 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.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/MemoryRegion.cs b/InterprocessMemory/MemoryRegion.cs index 4c1fe57..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\\"; + + /// + /// 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. + /// 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')) @@ -1636,6 +1661,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}"); 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)))(); + } } } 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