Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>`
and `SharedArray<T>` 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).
Expand Down
215 changes: 215 additions & 0 deletions InterprocessMemory.Tests/RemainingLimitationsTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Limitations that were documented in 4.0.0 and are handled now.</summary>
[TestFixture]
public class RemainingLimitationsTests
{
private static string N(string prefix) => $"Limits_{prefix}_{Guid.NewGuid():N}";

public struct WideSchema : IMemorySchema
{
public IEnumerable<FieldDefinition> GetFields()
{
yield return FieldDefinition.Scalar<Guid>("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<WideSchema>.CreateOrOpen(name, new WideSchema());
using var b = StructuredMemory<WideSchema>.OpenExisting(name, new WideSchema());

using (a.AcquireWriteLock())
{
var sw = Stopwatch.StartNew();
var ex = Assert.Throws<InvalidOperationException>(() => 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<WideSchema>.CreateOrOpen(name, new WideSchema());
using var b = StructuredMemory<WideSchema>.OpenExisting(name, new WideSchema());

using (a.AcquireWriteLock())
{
var sw = Stopwatch.StartNew();
Assert.Throws<InvalidOperationException>(() => 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<WideSchema>.CreateOrOpen(name, new WideSchema());
using var b = StructuredMemory<WideSchema>.OpenExisting(name, new WideSchema());

using (a.AcquireWriteLock())
{
// A 16 byte value is written under the shared lock automatically.
var sw = Stopwatch.StartNew();
Assert.Throws<InvalidOperationException>(() => 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<int>.CreateOrOpen(name, 8);
using var b = SharedArray<int>.OpenExisting(name);

using (a.AcquireWriteLock())
{
var sw = Stopwatch.StartNew();
Assert.Throws<InvalidOperationException>(() => b.AcquireWriteLock(TimeSpan.FromSeconds(10)));
Assert.Throws<InvalidOperationException>(() => 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<WideSchema>.CreateOrOpen(name, new WideSchema());
using var b = StructuredMemory<WideSchema>.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<TimeoutException>());
}
}

// ── 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<ArgumentException>(
() => 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 }));
}
}
}
24 changes: 21 additions & 3 deletions InterprocessMemory.Tests/SharedArrayTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand All @@ -580,8 +593,12 @@ public void ExplicitWriteLock_ExcludesOtherInstances_ForAnyElementType()

using (owner.AcquireWriteLock())
{
Assert.Throws<TimeoutException>(() => other.AcquireWriteLock(TimeSpan.FromMilliseconds(100)));
Assert.Throws<TimeoutException>(() => 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<TimeoutException>());
Assert.That(OnAnotherThread(() => other.AcquireReadLock(TimeSpan.FromMilliseconds(100))),
Is.InstanceOf<TimeoutException>());
}

using (other.AcquireWriteLock(TimeSpan.FromSeconds(1)))
Expand Down Expand Up @@ -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<TimeoutException>(() => peer.AcquireReadLock(TimeSpan.FromMilliseconds(100)));
Assert.That(OnAnotherThread(() => peer.AcquireReadLock(TimeSpan.FromMilliseconds(100))),
Is.InstanceOf<TimeoutException>());
}

using (peer.AcquireWriteLock(TimeSpan.FromSeconds(1)))
Expand Down
60 changes: 60 additions & 0 deletions InterprocessMemory.Tests/TypeLayoutFingerprintTests.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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<T>() 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<ExplicitFields>(), 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<AutoMixed>();
int[] sizes = { 1, 8, 2, 4 };
int total = System.Runtime.CompilerServices.Unsafe.SizeOf<AutoMixed>();
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()
{
Expand Down
Loading
Loading