diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..f64f9b2
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,46 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ validate:
+ name: Validate (${{ matrix.os }})
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 20
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, windows-latest]
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 10.0.x
+ - run: dotnet restore SharedMemoryStore.slnx
+ - run: dotnet build SharedMemoryStore.slnx -c Release --no-restore
+ - run: dotnet format SharedMemoryStore.slnx --verify-no-changes --no-restore
+ - run: dotnet test SharedMemoryStore.slnx -c Release --no-build
+ - shell: pwsh
+ run: ./scripts/validate-docs.ps1
+ - shell: pwsh
+ run: ./scripts/validate-package-consumption.ps1 -Configuration Release
+ - run: dotnet pack src/SharedMemoryStore/SharedMemoryStore.csproj -c Release --no-build -o artifacts/package
+
+ docker:
+ name: Docker sharing
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 10.0.x
+ - shell: pwsh
+ run: ./scripts/validate-docker-shared-memory.ps1 -Configuration Release
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0e37897..d560470 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,37 @@
All notable package and documentation changes are recorded in reverse
chronological order.
+## 1.0.1 - 2026-07-09
+
+### Fixed
+
+- Enforced one caller-selected wait budget across same-handle synchronization,
+ Linux lifecycle locking, and shared store locking.
+- Replaced destructive key-index rebuilds with crash-safe backward-shift
+ compaction and duplicate-tolerant cleanup.
+- Made Linux owner metadata replacement atomic and resistant to PID reuse, and
+ prevented failed opens from leaving live-looking owner records.
+- Rejected layout dimensions whose index, alignment, or section calculations
+ cannot be represented safely.
+- Moved stores that detect usage-count underflow into shared safe error mode.
+- Prevented Windows mapping-handle leaks when view creation fails and removed
+ process-local Linux lock registry growth after handles are disposed.
+- Matched Windows `Global\` mapping names with global synchronization so
+ cross-session participants cannot mutate one mapping under different mutexes.
+- Made segmented publish use one synchronized operation so bounded contention
+ cannot strand an internal reservation.
+
+### Security
+
+- Linux shared-memory directories now use owner-only `0700` permissions and
+ region, synchronization, owner, and lifecycle files use owner-only `0600`
+ permissions.
+
+### Compatibility
+
+- Public API names, status values, and shared-memory layout remain compatible
+ with the `1.0.0` production contract.
+
## 1.0.0 - 2026-07-03
### Added
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..05d843f
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,7 @@
+
+
+ true
+ true
+ all
+
+
diff --git a/README.md b/README.md
index 95d03e4..1f6a949 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@ readers can exchange data without copying payloads through a broker process.
Package identity:
- PackageId: `SharedMemoryStore`
-- Version: `1.0.0`
+- Version: `1.0.1`
- Target framework: `net10.0`
- License: MIT, see the [license file](LICENSE)
- Runtime dependencies: .NET BCL only
@@ -47,7 +47,7 @@ Docker into distributed storage.
For package consumers, start with [Getting started](docs/getting-started.md) and
the [Usage guide](docs/usage.md). A local package source workflow is documented
-because this prerelease repository may be consumed before a public NuGet publish.
+when validating a local build before consuming the published package.
```powershell
dotnet pack src/SharedMemoryStore/SharedMemoryStore.csproj -c Release -o artifacts/package
@@ -204,4 +204,4 @@ pwsh ./scripts/validate-docker-shared-memory.ps1
Documentation changes must keep package metadata, README content, release notes,
support policy, security policy, and contract links aligned with the current
-`1.0.0` package behavior.
+`1.0.1` package behavior.
diff --git a/SECURITY.md b/SECURITY.md
index 5ad002f..5890d3f 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -1,26 +1,29 @@
# Security Policy
-SharedMemoryStore is currently a prerelease `0.1.0` library. Security support is
-best effort until maintainers publish a stable support policy.
+SharedMemoryStore `1.0.x` receives best-effort community security fixes. This
+policy does not promise response or remediation service levels.
## Supported Versions
| Version | Support |
|---------|---------|
-| `0.1.x` | Best-effort prerelease security review |
+| `1.0.x` | Supported with best-effort community security fixes |
+| `< 1.0` | Unsupported |
Older unpublished or local builds are not independently supported. Users should
reproduce reports against the latest package version available to them.
## Reporting a Vulnerability
-Use GitHub private vulnerability reporting or a repository security advisory for
-this project. Do not include exploit details, private payloads, credentials, or
-reproduction data in public issues or pull requests.
+Use GitHub private vulnerability reporting or a repository security advisory
+for this project when that channel is enabled. Do not include exploit details,
+private payloads, credentials, or reproduction data in public issues or pull
+requests.
-If private vulnerability reporting is not enabled for the hosted repository
-before publication, maintainers must replace this guidance with an
-owner-approved private contact path before publishing a public release.
+Enabling private vulnerability reporting, or publishing an owner-approved
+private contact path, is a mandatory release gate. If neither is available,
+open only a minimal public issue requesting a secure contact channel and do not
+include vulnerability details.
Include:
diff --git a/SUPPORT.md b/SUPPORT.md
index 0ae0c3c..3283d10 100644
--- a/SUPPORT.md
+++ b/SUPPORT.md
@@ -1,6 +1,6 @@
# Support
-SharedMemoryStore is a prerelease package. Support is best effort and does not
+SharedMemoryStore `1.0.x` is a stable community package. Support is best effort and does not
include response-time service levels, production incident response, or paid
support commitments.
@@ -30,7 +30,7 @@ Useful reports include:
## Unsupported Scenarios
-The current prerelease does not claim support for:
+The current package does not claim support for:
- C++ or Python bindings.
- macOS runtime support.
diff --git a/docs/getting-started.md b/docs/getting-started.md
index b025a84..5e7c600 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -11,7 +11,7 @@ create/open, publish, acquire, release, remove, and dispose workflow.
- Docker Engine or Docker Desktop only when validating same-host container
sharing.
-The package version is `1.0.0`. If it has not been published to a package
+The package version is `1.0.1`. If it has not been published to a package
feed, build a local package source from the repository.
## Create a Local Package Source
diff --git a/docs/lifecycle.md b/docs/lifecycle.md
index d24e051..8ae0b48 100644
--- a/docs/lifecycle.md
+++ b/docs/lifecycle.md
@@ -124,6 +124,11 @@ the store toward safe error outcomes such as `CorruptStore`.
- Use `TryRecoverLeases` and `TryRecoverReservations` only when owner policy
permits recovery.
+Linux removes region, synchronization, and owner metadata after the final live
+handle closes. A zero-length per-name lifecycle lock file may remain so a later
+opener cannot race a different lock inode; applications should use stable store
+names rather than generating an unbounded sequence of one-time names.
+
## Long-Running Identity
Reusable slots carry generation and reuse-epoch identity. Index entries, lease
diff --git a/docs/packaging.md b/docs/packaging.md
index b3436fd..9fe435d 100644
--- a/docs/packaging.md
+++ b/docs/packaging.md
@@ -9,14 +9,15 @@ and targets `net10.0`. Runtime dependencies are limited to the .NET BCL.
| Field | Value |
|-------|-------|
| `PackageId` | `SharedMemoryStore` |
-| `Version` | `1.0.0` |
+| `Version` | `1.0.1` |
| `TargetFramework` | `net10.0` |
| `Description` | `A bounded named shared-memory key-value store for opaque binary values.` |
| `PackageTags` | `shared-memory;memory-mapped-file;zero-copy;linux;windows;docker;library` |
| `PackageLicenseExpression` | `MIT` |
| `PackageReadmeFile` | `README.md` |
-| `PackageReleaseNotes` | `Linux, Windows, and same-host Docker support: adds platform resource adapters, Docker validation, portable development workflows, and updated platform documentation while preserving the 1.0.0 public API contract.` |
+| `PackageReleaseNotes` | `Linux, Windows, and same-host Docker support hardening: fixes bounded waits, crash-safe ownership and index maintenance, private Linux resource permissions, layout overflow validation, and cleanup reliability while preserving the 1.0.0 public API and layout.` |
| `RepositoryType` | `git` |
+| `RepositoryUrl` | `https://github.com/rantri/SharedMemoryStore` |
The package project packs the root [README.md](../README.md) at the package
root so NuGet consumers see the same package purpose, status, first-use
diff --git a/docs/portability.md b/docs/portability.md
index 1a5ed05..77813fb 100644
--- a/docs/portability.md
+++ b/docs/portability.md
@@ -16,7 +16,7 @@ Detailed sources:
## Current Baseline
-- Runtime package: `SharedMemoryStore` `1.0.0`.
+- Runtime package: `SharedMemoryStore` `1.0.1`.
- Target framework: `net10.0`.
- Supported host platforms: Linux and Windows.
- Supported container profile: Linux-based same-host Docker containers with
@@ -70,12 +70,20 @@ API. Future implementations or bindings must document the same trust boundary.
## Platform Resource Model
Windows uses named operating-system memory mappings and named synchronization.
+An explicit `Global\` mapping name uses global synchronization as of `1.0.1`;
+all participants in such a store must use `1.0.1` or later. Ordinary unqualified
+and explicit `Local\` names retain session-local synchronization.
Linux uses deterministic files in a shared runtime memory location such as
`/dev/shm`, with names derived from the public store name and a collision
prevention hash. Docker containers participate in the Linux model only when
their IPC and process-liveness configuration lets all participants see the same
resources and classify owners safely.
+The Linux resource directory is owner-only (`0700`), and region,
+synchronization, owner, and lifecycle files are owner-only (`0600`). Cooperating
+host processes must therefore run as the same Unix identity. Containers must
+share a compatible identity as well as IPC and process-liveness namespaces.
+
## Unsupported Scenarios
- Current public docs do not claim C++ or Python bindings exist.
diff --git a/docs/releases.md b/docs/releases.md
index 8456cef..d112adb 100644
--- a/docs/releases.md
+++ b/docs/releases.md
@@ -1,7 +1,7 @@
# Release Preparation
This guide is the maintainer checklist for preparing a package release. It is
-written for the current `1.0.0` package and should be updated when package
+written for the current `1.0.1` package and should be updated when package
metadata, public API behavior, compatibility scope, support policy, security
reporting, documentation scope, or sample behavior changes.
@@ -183,6 +183,27 @@ dotnet run --project benchmarks/SharedMemoryStore.Benchmarks/SharedMemoryStore.B
dotnet run --project benchmarks/SharedMemoryStore.Benchmarks/SharedMemoryStore.Benchmarks.csproj -c Release -- --filter *SegmentedPublish*
```
+## 1.0.1 Production Hardening Notes
+
+The 2026-07-09 review completed the following release evidence:
+
+- Windows Release build completed with zero warnings and 153 tests passed.
+- Linux .NET 10 container validation passed 43 contract, 62 unit, and 47
+ integration tests; the package-consumption path was validated separately in
+ a clean Linux container.
+- The full Docker matrix passed supported sharing, advanced ingest, abrupt-exit
+ recovery, contention, disposal race, isolated negative behavior, and clean
+ packed-package consumption.
+- Documentation validation, formatting/analyzers, package packing, Windows
+ clean-consumer validation, and current transitive NuGet vulnerability checks
+ passed.
+- Public API names, status values, runtime dependencies, and the shared-memory
+ layout remain compatible with `1.0.0`.
+
+External publication gate: the GitHub repository reported private
+vulnerability reporting as disabled during this review. Enable it or publish an
+owner-approved private reporting channel before publishing `1.0.1`.
+
## 1.0.0 Documentation and Samples Excellence Notes
The documentation and samples excellence update reorganizes the reader journey,
diff --git a/docs/usage.md b/docs/usage.md
index b359452..74b63af 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -209,10 +209,11 @@ ReadOnlySequence payload = GetPayloadSequence();
var publish = store.TryPublishSegments(key, payload, descriptor, out var copiedBytes);
```
-The helper reserves a contiguous slot, copies each segment in order, advances
-reservation progress, and commits only after the copied byte count matches the
-sequence length. On copy, advance, or commit failure, it aborts the active
-reservation before returning.
+The helper acquires shared synchronization once, reserves a contiguous slot,
+copies each segment in order, and publishes only after the copied byte count
+matches the sequence length. A copy or validation failure reclaims the internal
+slot before synchronization is released, so bounded contention cannot strand a
+caller-inaccessible reservation.
## Diagnostics
diff --git a/scripts/validate-docker-shared-memory.ps1 b/scripts/validate-docker-shared-memory.ps1
index d2aef24..d9804fb 100644
--- a/scripts/validate-docker-shared-memory.ps1
+++ b/scripts/validate-docker-shared-memory.ps1
@@ -94,7 +94,7 @@ function Invoke-DockerCleanConsumerValidation {
enable
-
+
'@
diff --git a/scripts/validate-docs.ps1 b/scripts/validate-docs.ps1
index 7479b3a..3da9490 100644
--- a/scripts/validate-docs.ps1
+++ b/scripts/validate-docs.ps1
@@ -264,11 +264,12 @@ function Assert-PackageMetadata {
$expected = @{
TargetFramework = "net10.0"
PackageId = "SharedMemoryStore"
- Version = "1.0.0"
+ Version = "1.0.1"
Description = "A bounded named shared-memory key-value store for opaque binary values."
PackageLicenseExpression = "MIT"
PackageReadmeFile = "README.md"
RepositoryType = "git"
+ RepositoryUrl = "https://github.com/rantri/SharedMemoryStore"
}
foreach ($name in $expected.Keys) {
@@ -298,7 +299,7 @@ function Assert-PackageMetadata {
}
Assert-Contains "README.md" "SharedMemoryStore" "package README identity"
- Assert-Contains "README.md" "1.0.0" "package version alignment"
+ Assert-Contains "README.md" "1.0.1" "package version alignment"
Assert-Contains "README.md" "net10.0" "target framework alignment"
Assert-Contains "README.md" "MIT" "license alignment"
Assert-Contains "LICENSE" "MIT License" "license metadata alignment"
diff --git a/specs/003-zero-copy-ingest/contracts/reservation-api.md b/specs/003-zero-copy-ingest/contracts/reservation-api.md
index 93c3383..d99f74d 100644
--- a/specs/003-zero-copy-ingest/contracts/reservation-api.md
+++ b/specs/003-zero-copy-ingest/contracts/reservation-api.md
@@ -115,14 +115,14 @@ and compatibility contract belongs to the span, memory, and sequence APIs above.
## Segmented Publish Rules
-- `TryPublishSegments` is a helper over the reservation lifecycle.
+- `TryPublishSegments` applies the same pending-to-published safety rules as a
+ reservation while holding one shared-synchronization acquisition.
- `ReadOnlySequence.Length` must fit in `int` and not exceed
`MaxValueBytes`.
-- The helper copies segments in order into one reserved payload region,
- advances the reservation by each segment length, and commits only after the
- copied byte count equals the sequence length.
-- If segment copy fails or commit fails, the helper aborts any active
- reservation before returning.
+- The helper copies segments in order into one reserved payload region and
+ publishes only after the copied byte count equals the sequence length.
+- If segment copy or validation fails, the helper reclaims the unpublished slot
+ before releasing shared synchronization.
- The helper must not allocate a temporary contiguous full-payload array.
- The stored value bytes must equal the logical concatenation of all segments.
diff --git a/src/SharedMemoryStore/Ingest/ReservationMemoryManager.cs b/src/SharedMemoryStore/Ingest/ReservationMemoryManager.cs
index 91aed20..f6ae643 100644
--- a/src/SharedMemoryStore/Ingest/ReservationMemoryManager.cs
+++ b/src/SharedMemoryStore/Ingest/ReservationMemoryManager.cs
@@ -8,18 +8,15 @@ internal sealed unsafe class ReservationMemoryManager : IDisposable
{
private readonly byte* _payloadStorage;
private readonly int _payloadStride;
- private readonly SlotPayloadMemoryManager[] _payloads;
+ private readonly int _maxValueBytes;
+ private readonly Dictionary _payloads = new();
private bool _disposed;
public ReservationMemoryManager(MemoryMappedStoreRegion region, StoreLayout layout)
{
_payloadStorage = region.Pointer + layout.PayloadStorageOffset;
_payloadStride = layout.PayloadStride;
- _payloads = new SlotPayloadMemoryManager[layout.SlotCount];
- for (var i = 0; i < _payloads.Length; i++)
- {
- _payloads[i] = new SlotPayloadMemoryManager(_payloadStorage + ((long)i * _payloadStride), layout.MaxValueBytes);
- }
+ _maxValueBytes = layout.MaxValueBytes;
}
public Span GetSpan(int slotIndex, int offset, int length)
@@ -39,7 +36,15 @@ public Memory GetMemory(int slotIndex, int offset, int length)
return Memory.Empty;
}
- return _payloads[slotIndex].Memory.Slice(offset, length);
+ if (!_payloads.TryGetValue(slotIndex, out var payload))
+ {
+ payload = new SlotPayloadMemoryManager(
+ _payloadStorage + ((long)slotIndex * _payloadStride),
+ _maxValueBytes);
+ _payloads.Add(slotIndex, payload);
+ }
+
+ return payload.Memory.Slice(offset, length);
}
public void Dispose()
@@ -50,10 +55,12 @@ public void Dispose()
}
_disposed = true;
- for (var i = 0; i < _payloads.Length; i++)
+ foreach (var payload in _payloads.Values)
{
- ((IDisposable)_payloads[i]).Dispose();
+ ((IDisposable)payload).Dispose();
}
+
+ _payloads.Clear();
}
private sealed unsafe class SlotPayloadMemoryManager : MemoryManager
diff --git a/src/SharedMemoryStore/Ingest/ReservationRecovery.cs b/src/SharedMemoryStore/Ingest/ReservationRecovery.cs
index b03b0c1..b4b8689 100644
--- a/src/SharedMemoryStore/Ingest/ReservationRecovery.cs
+++ b/src/SharedMemoryStore/Ingest/ReservationRecovery.cs
@@ -71,7 +71,7 @@ public static StoreStatus Recover(
}
var lifecycleId = SlotLifecycleId.FromSlot(slot);
- if (!index.TryRemoveSlot(i, lifecycleId))
+ if (!index.TryRemoveSlot(i, lifecycleId, slot.KeyHash))
{
failed++;
continue;
diff --git a/src/SharedMemoryStore/Ingest/SegmentedPublisher.cs b/src/SharedMemoryStore/Ingest/SegmentedPublisher.cs
deleted file mode 100644
index 86eea89..0000000
--- a/src/SharedMemoryStore/Ingest/SegmentedPublisher.cs
+++ /dev/null
@@ -1,69 +0,0 @@
-using System.Buffers;
-
-namespace SharedMemoryStore.Ingest;
-
-internal static class SegmentedPublisher
-{
- public static StoreStatus Publish(
- MemoryStore store,
- ReadOnlySpan key,
- in ReadOnlySequence payload,
- ReadOnlySpan descriptor,
- StoreWaitOptions waitOptions,
- out long copiedBytes)
- {
- copiedBytes = 0;
- if (payload.Length > int.MaxValue)
- {
- return StoreStatus.ValueTooLarge;
- }
-
- var status = store.TryReserve(key, (int)payload.Length, descriptor, waitOptions, out var reservation);
- if (status != StoreStatus.Success)
- {
- return status;
- }
-
- try
- {
- foreach (var segment in payload)
- {
- var source = segment.Span;
- while (!source.IsEmpty)
- {
- var target = reservation.GetSpan(source.Length);
- if (target.IsEmpty)
- {
- _ = reservation.Abort(waitOptions);
- return StoreStatus.ReservationWriteOutOfRange;
- }
-
- var copyLength = Math.Min(source.Length, target.Length);
- source[..copyLength].CopyTo(target);
- status = reservation.Advance(copyLength, waitOptions);
- if (status != StoreStatus.Success)
- {
- _ = reservation.Abort(waitOptions);
- return status;
- }
-
- copiedBytes += copyLength;
- source = source[copyLength..];
- }
- }
-
- status = reservation.Commit(waitOptions);
- if (status != StoreStatus.Success)
- {
- _ = reservation.Abort(waitOptions);
- }
-
- return status;
- }
- catch
- {
- _ = reservation.Abort(waitOptions);
- return StoreStatus.UnknownFailure;
- }
- }
-}
diff --git a/src/SharedMemoryStore/Interop/LinuxFileLock.cs b/src/SharedMemoryStore/Interop/LinuxFileLock.cs
index 47dd529..e3920b2 100644
--- a/src/SharedMemoryStore/Interop/LinuxFileLock.cs
+++ b/src/SharedMemoryStore/Interop/LinuxFileLock.cs
@@ -7,10 +7,11 @@ namespace SharedMemoryStore.Interop;
[SupportedOSPlatform("linux")]
internal sealed class LinuxFileLock : IDisposable
{
- private static readonly ConcurrentDictionary LocalLocks = new(StringComparer.Ordinal);
+ private static readonly ConcurrentDictionary LocalLocks = new(StringComparer.Ordinal);
private readonly FileStream _stream;
- private readonly object _localLock;
+ private readonly string _localLockPath;
+ private readonly LocalLockEntry _localLockEntry;
private bool _locked;
private bool _localLockHeld;
private bool _disposed;
@@ -18,7 +19,8 @@ internal sealed class LinuxFileLock : IDisposable
private LinuxFileLock(string path, FileStream stream)
{
_stream = stream;
- _localLock = LocalLocks.GetOrAdd(Path.GetFullPath(path), _ => new object());
+ _localLockPath = Path.GetFullPath(path);
+ _localLockEntry = AcquireLocalLockEntry(_localLockPath);
}
public static StoreStatus TryAcquire(
@@ -35,12 +37,7 @@ public static StoreStatus TryAcquire(
FileStream stream;
try
{
- Directory.CreateDirectory(Path.GetDirectoryName(path) ?? ".");
- stream = new FileStream(
- path,
- FileMode.OpenOrCreate,
- FileAccess.ReadWrite,
- FileShare.ReadWrite | FileShare.Delete);
+ stream = OpenLockFile(path);
}
catch (UnauthorizedAccessException)
{
@@ -143,12 +140,7 @@ public static StoreStatus TryOpen(string path, out LinuxFileLock? fileLock)
fileLock = null;
try
{
- Directory.CreateDirectory(Path.GetDirectoryName(path) ?? ".");
- var stream = new FileStream(
- path,
- FileMode.OpenOrCreate,
- FileAccess.ReadWrite,
- FileShare.ReadWrite | FileShare.Delete);
+ var stream = OpenLockFile(path);
fileLock = new LinuxFileLock(path, stream);
return StoreStatus.Success;
@@ -185,7 +177,7 @@ public void Release()
if (_localLockHeld)
{
- Monitor.Exit(_localLock);
+ Monitor.Exit(_localLockEntry.SyncRoot);
_localLockHeld = false;
}
}
@@ -200,13 +192,14 @@ public void Dispose()
_disposed = true;
Release();
_stream.Dispose();
+ ReleaseLocalLockEntry(_localLockPath, _localLockEntry);
}
private bool TryAcquireLocal(StoreWaitOptions waitOptions, long startTimestamp)
{
while (true)
{
- if (Monitor.TryEnter(_localLock))
+ if (Monitor.TryEnter(_localLockEntry.SyncRoot))
{
_localLockHeld = true;
return true;
@@ -240,4 +233,67 @@ private static bool WaitBeforeRetry(StoreWaitOptions waitOptions, long startTime
return waitOptions.CancellationToken.WaitHandle.WaitOne(sleep) == false;
}
+
+ private static FileStream OpenLockFile(string path)
+ {
+ LinuxSharedMemoryDirectory.EnsureExists(Path.GetDirectoryName(path) ?? ".");
+ var stream = new FileStream(path, new FileStreamOptions
+ {
+ Mode = FileMode.OpenOrCreate,
+ Access = FileAccess.ReadWrite,
+ Share = FileShare.ReadWrite | FileShare.Delete,
+ UnixCreateMode = LinuxSharedMemoryDirectory.PrivateFileMode
+ });
+ File.SetUnixFileMode(path, LinuxSharedMemoryDirectory.PrivateFileMode);
+ return stream;
+ }
+
+ private static LocalLockEntry AcquireLocalLockEntry(string path)
+ {
+ while (true)
+ {
+ var entry = LocalLocks.GetOrAdd(path, static _ => new LocalLockEntry());
+ lock (entry.ReferenceGate)
+ {
+ if (entry.Retired)
+ {
+ continue;
+ }
+
+ entry.ReferenceCount++;
+ return entry;
+ }
+ }
+ }
+
+ private static void ReleaseLocalLockEntry(string path, LocalLockEntry entry)
+ {
+ var remove = false;
+ lock (entry.ReferenceGate)
+ {
+ entry.ReferenceCount--;
+ if (entry.ReferenceCount == 0)
+ {
+ entry.Retired = true;
+ remove = true;
+ }
+ }
+
+ if (remove)
+ {
+ _ = ((ICollection>)LocalLocks).Remove(
+ new KeyValuePair(path, entry));
+ }
+ }
+
+ private sealed class LocalLockEntry
+ {
+ public object SyncRoot { get; } = new();
+
+ public object ReferenceGate { get; } = new();
+
+ public int ReferenceCount { get; set; }
+
+ public bool Retired { get; set; }
+ }
}
diff --git a/src/SharedMemoryStore/Interop/LinuxSharedMemoryRegion.cs b/src/SharedMemoryStore/Interop/LinuxSharedMemoryRegion.cs
index dcc5839..2d69025 100644
--- a/src/SharedMemoryStore/Interop/LinuxSharedMemoryRegion.cs
+++ b/src/SharedMemoryStore/Interop/LinuxSharedMemoryRegion.cs
@@ -10,13 +10,14 @@ internal static class LinuxSharedMemoryRegion
public static StoreOpenStatus TryOpen(
PlatformResourceName resourceName,
SharedMemoryStoreOptions options,
+ StoreWaitOptions waitOptions,
out MemoryMappedStoreRegion? region)
{
region = null;
var lifecycleLockStatus = LinuxFileLock.TryAcquire(
resourceName.LinuxLifecycleLockPath,
- StoreWaitOptions.Infinite,
+ waitOptions,
out var lifecycleLock);
if (lifecycleLockStatus != StoreStatus.Success || lifecycleLock is null)
{
@@ -27,7 +28,7 @@ public static StoreOpenStatus TryOpen(
{
try
{
- Directory.CreateDirectory(Path.GetDirectoryName(resourceName.LinuxRegionPath) ?? ".");
+ LinuxSharedMemoryDirectory.EnsureExists(Path.GetDirectoryName(resourceName.LinuxRegionPath) ?? ".");
var liveOwners = ReadLiveOwnerRecords(resourceName.LinuxOwnersPath);
var hasLiveResource = File.Exists(resourceName.LinuxRegionPath) && liveOwners.Count > 0;
if (!hasLiveResource)
@@ -67,19 +68,33 @@ private static StoreOpenStatus CreateRegion(
out MemoryMappedStoreRegion? region)
{
region = null;
+ FileStream stream;
+ try
+ {
+ stream = new FileStream(resourceName.LinuxRegionPath, new FileStreamOptions
+ {
+ Mode = FileMode.CreateNew,
+ Access = FileAccess.ReadWrite,
+ Share = FileShare.ReadWrite | FileShare.Delete,
+ UnixCreateMode = LinuxSharedMemoryDirectory.PrivateFileMode
+ });
+ File.SetUnixFileMode(resourceName.LinuxRegionPath, LinuxSharedMemoryDirectory.PrivateFileMode);
+ }
+ catch (IOException) when (File.Exists(resourceName.LinuxRegionPath))
+ {
+ return StoreOpenStatus.AlreadyExists;
+ }
+
try
{
- var stream = new FileStream(
- resourceName.LinuxRegionPath,
- FileMode.CreateNew,
- FileAccess.ReadWrite,
- FileShare.ReadWrite | FileShare.Delete);
stream.SetLength(options.TotalBytes);
return CreateMappedRegion(resourceName, options, stream, out region);
}
- catch (IOException)
+ catch
{
- return StoreOpenStatus.AlreadyExists;
+ stream.Dispose();
+ DeleteIfExists(resourceName.LinuxRegionPath);
+ throw;
}
}
@@ -99,6 +114,7 @@ private static StoreOpenStatus OpenExistingRegion(
FileMode.Open,
FileAccess.ReadWrite,
FileShare.ReadWrite | FileShare.Delete);
+ File.SetUnixFileMode(resourceName.LinuxRegionPath, LinuxSharedMemoryDirectory.PrivateFileMode);
if (stream.Length < options.TotalBytes)
{
@@ -119,6 +135,8 @@ private static StoreOpenStatus CreateMappedRegion(
var ownerRecord = CreateOwnerRecord();
MemoryMappedFile? mapping = null;
MemoryMappedViewAccessor? accessor = null;
+ MemoryMappedStoreRegion? candidate = null;
+ var ownerRegistered = false;
try
{
mapping = MemoryMappedFile.CreateFromFile(
@@ -130,18 +148,28 @@ private static StoreOpenStatus CreateMappedRegion(
leaveOpen: false);
accessor = mapping.CreateViewAccessor(0, options.TotalBytes, MemoryMappedFileAccess.ReadWrite);
- RegisterOwner(resourceName.LinuxOwnersPath, ownerRecord);
- region = MemoryMappedStoreRegion.Create(
+ candidate = MemoryMappedStoreRegion.Create(
mapping,
accessor,
options.TotalBytes,
- () => ReleaseOwner(resourceName, ownerRecord));
+ () =>
+ {
+ if (ownerRegistered)
+ {
+ ReleaseOwner(resourceName, ownerRecord);
+ }
+ });
mapping = null;
accessor = null;
+ RegisterOwner(resourceName.LinuxOwnersPath, ownerRecord);
+ ownerRegistered = true;
+ region = candidate;
+ candidate = null;
return StoreOpenStatus.Success;
}
catch
{
+ candidate?.Dispose();
accessor?.Dispose();
mapping?.Dispose();
stream.Dispose();
@@ -151,7 +179,11 @@ private static StoreOpenStatus CreateMappedRegion(
private static string CreateOwnerRecord()
{
- return Environment.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture) + ":" + Guid.NewGuid().ToString("N");
+ return string.Join(
+ ':',
+ Environment.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ GetProcessStartToken(Environment.ProcessId),
+ Guid.NewGuid().ToString("N"));
}
private static void RegisterOwner(string ownersPath, string ownerRecord)
@@ -209,7 +241,8 @@ private static List ReadLiveOwnerRecords(string ownersPath)
continue;
}
- if (TryReadProcessId(trimmed, out var processId) && IsProcessLive(processId))
+ if (TryReadOwnerIdentity(trimmed, out var processId, out var startToken)
+ && IsProcessLive(processId, startToken))
{
owners.Add(trimmed);
}
@@ -220,37 +253,86 @@ private static List ReadLiveOwnerRecords(string ownersPath)
private static void WriteOwners(string ownersPath, List owners)
{
- Directory.CreateDirectory(Path.GetDirectoryName(ownersPath) ?? ".");
- File.WriteAllLines(ownersPath, owners);
+ LinuxSharedMemoryDirectory.EnsureExists(Path.GetDirectoryName(ownersPath) ?? ".");
+ var temporaryPath = ownersPath + ".tmp";
+ try
+ {
+ using (var stream = new FileStream(temporaryPath, new FileStreamOptions
+ {
+ Mode = FileMode.Create,
+ Access = FileAccess.Write,
+ Share = FileShare.None,
+ UnixCreateMode = LinuxSharedMemoryDirectory.PrivateFileMode
+ }))
+ using (var writer = new StreamWriter(stream))
+ {
+ foreach (var owner in owners)
+ {
+ writer.WriteLine(owner);
+ }
+ }
+
+ File.SetUnixFileMode(temporaryPath, LinuxSharedMemoryDirectory.PrivateFileMode);
+ File.Move(temporaryPath, ownersPath, overwrite: true);
+ }
+ finally
+ {
+ try
+ {
+ DeleteIfExists(temporaryPath);
+ }
+ catch
+ {
+ // A later owner update or stale-resource cleanup will retry temporary-file cleanup.
+ }
+ }
}
- private static bool TryReadProcessId(string ownerRecord, out int processId)
+ private static bool TryReadOwnerIdentity(string ownerRecord, out int processId, out string? startToken)
{
- var separator = ownerRecord.IndexOf(':');
- var value = separator < 0 ? ownerRecord : ownerRecord[..separator];
- return int.TryParse(
- value,
+ processId = 0;
+ startToken = null;
+ var parts = ownerRecord.Split(':', 3);
+ if (!int.TryParse(
+ parts[0],
System.Globalization.NumberStyles.None,
System.Globalization.CultureInfo.InvariantCulture,
- out processId);
+ out processId))
+ {
+ return false;
+ }
+
+ if (parts.Length >= 3)
+ {
+ startToken = parts[1];
+ }
+
+ return true;
}
- private static bool IsProcessLive(int processId)
+ private static bool IsProcessLive(int processId, string? startToken)
{
if (processId <= 0)
{
return false;
}
- if (processId == Environment.ProcessId)
- {
- return true;
- }
-
try
{
using var process = Process.GetProcessById(processId);
- return !process.HasExited;
+ if (process.HasExited)
+ {
+ return false;
+ }
+
+ if (string.IsNullOrEmpty(startToken))
+ {
+ return true;
+ }
+
+ var observedStartToken = GetProcessStartToken(processId);
+ return observedStartToken.Length == 0
+ || string.Equals(observedStartToken, startToken, StringComparison.Ordinal);
}
catch (ArgumentException)
{
@@ -266,11 +348,44 @@ private static bool IsProcessLive(int processId)
}
}
+ private static string GetProcessStartToken(int processId)
+ {
+ try
+ {
+ var stat = File.ReadAllText($"/proc/{processId}/stat");
+ var commandEnd = stat.LastIndexOf(')');
+ if (commandEnd >= 0 && commandEnd + 2 < stat.Length)
+ {
+ var fields = stat[(commandEnd + 2)..].Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ if (fields.Length > 19)
+ {
+ return "proc-" + fields[19];
+ }
+ }
+ }
+ catch
+ {
+ // Fall back to the runtime process timestamp when procfs is unavailable.
+ }
+
+ try
+ {
+ using var process = Process.GetProcessById(processId);
+ return "utc-" + process.StartTime.ToUniversalTime().Ticks.ToString(
+ System.Globalization.CultureInfo.InvariantCulture);
+ }
+ catch
+ {
+ return string.Empty;
+ }
+ }
+
private static void DeleteStaleResources(PlatformResourceName resourceName)
{
DeleteIfExists(resourceName.LinuxRegionPath);
DeleteIfExists(resourceName.LinuxSynchronizationPath);
DeleteIfExists(resourceName.LinuxOwnersPath);
+ DeleteIfExists(resourceName.LinuxOwnersPath + ".tmp");
}
private static void DeleteIfExists(string path)
diff --git a/src/SharedMemoryStore/Interop/MemoryMappedStoreRegion.cs b/src/SharedMemoryStore/Interop/MemoryMappedStoreRegion.cs
index f154f4e..4348985 100644
--- a/src/SharedMemoryStore/Interop/MemoryMappedStoreRegion.cs
+++ b/src/SharedMemoryStore/Interop/MemoryMappedStoreRegion.cs
@@ -46,7 +46,7 @@ public static MemoryMappedStoreRegion Create(
public static StoreOpenStatus TryOpen(SharedMemoryStoreOptions options, out MemoryMappedStoreRegion? region)
{
- return SharedStorePlatform.TryOpenRegion(options, out region);
+ return SharedStorePlatform.TryOpenRegion(options, StoreWaitOptions.Default, out region);
}
public void Dispose()
diff --git a/src/SharedMemoryStore/Interop/PlatformResourceName.cs b/src/SharedMemoryStore/Interop/PlatformResourceName.cs
index 602f0c4..ecac1f0 100644
--- a/src/SharedMemoryStore/Interop/PlatformResourceName.cs
+++ b/src/SharedMemoryStore/Interop/PlatformResourceName.cs
@@ -1,5 +1,6 @@
using System.Security.Cryptography;
using System.Text;
+using System.Runtime.Versioning;
namespace SharedMemoryStore.Interop;
@@ -64,7 +65,10 @@ public static PlatformResourceName Create(string publicName)
public static string BuildWindowsSynchronizationName(string publicName)
{
- return @"Local\SharedMemoryStore-" + string.Create(publicName.Length, publicName, static (destination, source) =>
+ var resourceScope = publicName.StartsWith(@"Global\", StringComparison.OrdinalIgnoreCase)
+ ? @"Global\"
+ : @"Local\";
+ return resourceScope + "SharedMemoryStore-" + string.Create(publicName.Length, publicName, static (destination, source) =>
{
for (var i = 0; i < source.Length; i++)
{
@@ -104,6 +108,11 @@ private static string BuildResourceFragment(string publicName)
internal static class LinuxSharedMemoryDirectory
{
+ public const UnixFileMode PrivateDirectoryMode =
+ UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute;
+
+ public const UnixFileMode PrivateFileMode = UnixFileMode.UserRead | UnixFileMode.UserWrite;
+
public static string GetPath()
{
var root = Directory.Exists("/dev/shm")
@@ -112,4 +121,18 @@ public static string GetPath()
return Path.Combine(root, "SharedMemoryStore");
}
+
+ [SupportedOSPlatform("linux")]
+ public static void EnsureExists(string path)
+ {
+ Directory.CreateDirectory(path, PrivateDirectoryMode);
+ var directory = new DirectoryInfo(path);
+ if (directory.LinkTarget is not null
+ || (directory.Attributes & FileAttributes.ReparsePoint) != 0)
+ {
+ throw new UnauthorizedAccessException("The SharedMemoryStore Linux resource directory must not be a symbolic link.");
+ }
+
+ File.SetUnixFileMode(path, PrivateDirectoryMode);
+ }
}
diff --git a/src/SharedMemoryStore/Interop/SharedStorePlatform.cs b/src/SharedMemoryStore/Interop/SharedStorePlatform.cs
index 216a185..5be4904 100644
--- a/src/SharedMemoryStore/Interop/SharedStorePlatform.cs
+++ b/src/SharedMemoryStore/Interop/SharedStorePlatform.cs
@@ -4,13 +4,14 @@ internal static class SharedStorePlatform
{
public static StoreOpenStatus TryOpen(
SharedMemoryStoreOptions options,
+ StoreWaitOptions waitOptions,
out MemoryMappedStoreRegion? region,
out ISharedStoreSynchronization? synchronization)
{
region = null;
synchronization = null;
- var status = TryOpenRegion(options, out region);
+ var status = TryOpenRegion(options, waitOptions, out region);
if (status != StoreOpenStatus.Success || region is null)
{
return status;
@@ -43,6 +44,7 @@ public static StoreOpenStatus TryOpen(
public static StoreOpenStatus TryOpenRegion(
SharedMemoryStoreOptions options,
+ StoreWaitOptions waitOptions,
out MemoryMappedStoreRegion? region)
{
region = null;
@@ -54,7 +56,7 @@ public static StoreOpenStatus TryOpenRegion(
if (OperatingSystem.IsLinux())
{
- return LinuxSharedMemoryRegion.TryOpen(resourceName, options, out region);
+ return LinuxSharedMemoryRegion.TryOpen(resourceName, options, waitOptions, out region);
}
return StoreOpenStatus.UnsupportedPlatform;
diff --git a/src/SharedMemoryStore/Interop/WindowsSharedMemoryRegion.cs b/src/SharedMemoryStore/Interop/WindowsSharedMemoryRegion.cs
index 4c16d92..a27fc9f 100644
--- a/src/SharedMemoryStore/Interop/WindowsSharedMemoryRegion.cs
+++ b/src/SharedMemoryStore/Interop/WindowsSharedMemoryRegion.cs
@@ -13,10 +13,12 @@ public static StoreOpenStatus TryOpen(
out MemoryMappedStoreRegion? region)
{
region = null;
+ MemoryMappedFile? mapping = null;
+ MemoryMappedViewAccessor? accessor = null;
try
{
- var mapping = options.OpenMode switch
+ mapping = options.OpenMode switch
{
OpenMode.CreateNew => MemoryMappedFile.CreateNew(
resourceName.WindowsRegionName,
@@ -31,15 +33,17 @@ public static StoreOpenStatus TryOpen(
MemoryMappedFileAccess.ReadWrite)
};
- var accessor = mapping.CreateViewAccessor(0, options.TotalBytes, MemoryMappedFileAccess.ReadWrite);
+ accessor = mapping.CreateViewAccessor(0, options.TotalBytes, MemoryMappedFileAccess.ReadWrite);
region = MemoryMappedStoreRegion.Create(mapping, accessor, options.TotalBytes);
+ mapping = null;
+ accessor = null;
return StoreOpenStatus.Success;
}
catch (FileNotFoundException) when (options.OpenMode == OpenMode.OpenExisting)
{
return StoreOpenStatus.NotFound;
}
- catch (IOException) when (options.OpenMode == OpenMode.CreateNew)
+ catch (IOException) when (options.OpenMode == OpenMode.CreateNew && mapping is null)
{
return StoreOpenStatus.AlreadyExists;
}
@@ -59,5 +63,10 @@ public static StoreOpenStatus TryOpen(
{
return StoreOpenStatus.MappingFailed;
}
+ finally
+ {
+ accessor?.Dispose();
+ mapping?.Dispose();
+ }
}
}
diff --git a/src/SharedMemoryStore/Layout/SharedKeyIndex.cs b/src/SharedMemoryStore/Layout/SharedKeyIndex.cs
index 24662b0..829ab7a 100644
--- a/src/SharedMemoryStore/Layout/SharedKeyIndex.cs
+++ b/src/SharedMemoryStore/Layout/SharedKeyIndex.cs
@@ -104,6 +104,7 @@ public bool TryRemove(ReadOnlySpan key, ulong hash)
{
var start = ProbeStart(hash);
var probes = 0;
+ var removed = false;
for (var step = 0; step < _layout.IndexEntryCount; step++)
{
@@ -115,7 +116,7 @@ public bool TryRemove(ReadOnlySpan key, ulong hash)
if (state == LayoutConstants.IndexEmpty)
{
RecordProbeLength(probes);
- return false;
+ return removed;
}
if (state == LayoutConstants.IndexOccupied
@@ -123,34 +124,43 @@ public bool TryRemove(ReadOnlySpan key, ulong hash)
&& StoreKey.Equals(KeyPointer(entryIndex), entry.KeyLength, key))
{
Volatile.Write(ref entry.State, LayoutConstants.IndexTombstone);
- RecordProbeLength(probes);
- return true;
+ removed = true;
}
}
RecordProbeLength(probes);
- return false;
+ return removed;
}
- public bool TryRemoveSlot(int slotIndex, SlotLifecycleId lifecycleId)
+ public bool TryRemoveSlot(int slotIndex, SlotLifecycleId lifecycleId, ulong hash)
{
+ var start = ProbeStart(hash);
var probes = 0;
- for (var entryIndex = 0; entryIndex < _layout.IndexEntryCount; entryIndex++)
+ var removed = false;
+ for (var step = 0; step < _layout.IndexEntryCount; step++)
{
probes++;
+ var entryIndex = (start + step) & (_layout.IndexEntryCount - 1);
ref var entry = ref Entry(entryIndex);
- if (Volatile.Read(ref entry.State) == LayoutConstants.IndexOccupied
+ var state = Volatile.Read(ref entry.State);
+ if (state == LayoutConstants.IndexEmpty)
+ {
+ RecordProbeLength(probes);
+ return removed;
+ }
+
+ if (state == LayoutConstants.IndexOccupied
+ && entry.KeyHash == hash
&& entry.SlotIndex == slotIndex
&& lifecycleId.Matches(entry.SlotGeneration, entry.SlotReuseEpoch))
{
Volatile.Write(ref entry.State, LayoutConstants.IndexTombstone);
- RecordProbeLength(probes);
- return true;
+ removed = true;
}
}
RecordProbeLength(probes);
- return false;
+ return removed;
}
public int OccupiedCount()
@@ -200,45 +210,86 @@ public IndexStateCounts CountStates()
public bool TryCompact()
{
- var occupied = new List(_layout.IndexEntryCount);
- for (var entryIndex = 0; entryIndex < _layout.IndexEntryCount; entryIndex++)
+ var compacted = false;
+ for (var pass = 0; pass < _layout.IndexEntryCount; pass++)
{
- ref var entry = ref Entry(entryIndex);
- if (Volatile.Read(ref entry.State) != LayoutConstants.IndexOccupied)
+ var changedThisPass = false;
+ for (var entryIndex = 0; entryIndex < _layout.IndexEntryCount; entryIndex++)
{
- continue;
+ if (Volatile.Read(ref Entry(entryIndex).State) != LayoutConstants.IndexTombstone
+ || !TryCompactHole(entryIndex))
+ {
+ continue;
+ }
+
+ changedThisPass = true;
+ compacted = true;
}
- var key = new byte[entry.KeyLength];
- new ReadOnlySpan(KeyPointer(entryIndex), entry.KeyLength).CopyTo(key);
- occupied.Add(new IndexEntrySnapshot(
- key,
- entry.KeyHash,
- entry.SlotIndex,
- SlotLifecycleId.FromIndex(entry)));
+ if (!changedThisPass)
+ {
+ break;
+ }
}
- for (var entryIndex = 0; entryIndex < _layout.IndexEntryCount; entryIndex++)
- {
- ref var entry = ref Entry(entryIndex);
- Volatile.Write(ref entry.State, LayoutConstants.IndexEmpty);
- entry.KeyLength = 0;
- entry.KeyHash = 0;
- entry.SlotIndex = -1;
- entry.SlotGeneration = 0;
- entry.SlotReuseEpoch = 0;
- new Span(KeyPointer(entryIndex), _layout.MaxKeyBytes).Clear();
- }
+ return compacted;
+ }
- foreach (var snapshot in occupied)
+ private bool TryCompactHole(int initialHole)
+ {
+ var mask = _layout.IndexEntryCount - 1;
+ var hole = initialHole;
+ var scan = (hole + 1) & mask;
+
+ for (var step = 0; step < _layout.IndexEntryCount; step++)
{
- if (!TryInsertWithoutProbeMetrics(snapshot.Key, snapshot.Hash, snapshot.SlotIndex, snapshot.LifecycleId))
+ ref var candidate = ref Entry(scan);
+ var state = Volatile.Read(ref candidate.State);
+ if (state == LayoutConstants.IndexEmpty)
{
- return false;
+ ClearEntry(hole);
+ return true;
+ }
+
+ if (state == LayoutConstants.IndexOccupied)
+ {
+ var home = ProbeStart(candidate.KeyHash);
+ var distanceToHole = (hole - home) & mask;
+ var distanceToCandidate = (scan - home) & mask;
+ if (distanceToHole < distanceToCandidate)
+ {
+ var lifecycleId = SlotLifecycleId.FromIndex(candidate);
+ WriteEntry(
+ hole,
+ new ReadOnlySpan(KeyPointer(scan), candidate.KeyLength),
+ candidate.KeyHash,
+ candidate.SlotIndex,
+ lifecycleId);
+
+ // Destination publication precedes source removal. A process crash can
+ // leave a harmless duplicate, and remove paths deliberately clear all
+ // matching copies.
+ Volatile.Write(ref candidate.State, LayoutConstants.IndexTombstone);
+ hole = scan;
+ }
}
+
+ scan = (scan + 1) & mask;
}
- return true;
+ return false;
+ }
+
+ private void ClearEntry(int entryIndex)
+ {
+ ref var entry = ref Entry(entryIndex);
+ Volatile.Write(ref entry.State, LayoutConstants.IndexEmpty);
+ entry.KeyLength = 0;
+ entry.KeyHash = 0;
+ entry.SlotIndex = -1;
+ entry.SlotGeneration = 0;
+ entry.SlotReuseEpoch = 0;
+ new Span(KeyPointer(entryIndex), _layout.MaxKeyBytes).Clear();
}
private void WriteEntry(int entryIndex, ReadOnlySpan key, ulong hash, int slotIndex, SlotLifecycleId lifecycleId)
@@ -256,23 +307,6 @@ private void WriteEntry(int entryIndex, ReadOnlySpan key, ulong hash, int
Volatile.Write(ref entry.State, LayoutConstants.IndexOccupied);
}
- private bool TryInsertWithoutProbeMetrics(ReadOnlySpan key, ulong hash, int slotIndex, SlotLifecycleId lifecycleId)
- {
- var start = ProbeStart(hash);
- for (var step = 0; step < _layout.IndexEntryCount; step++)
- {
- var entryIndex = (start + step) & (_layout.IndexEntryCount - 1);
- ref var entry = ref Entry(entryIndex);
- if (Volatile.Read(ref entry.State) == LayoutConstants.IndexEmpty)
- {
- WriteEntry(entryIndex, key, hash, slotIndex, lifecycleId);
- return true;
- }
- }
-
- return false;
- }
-
private void RecordProbeLength(int probeLength)
{
Volatile.Write(ref _lastObservedProbeLength, probeLength);
@@ -304,7 +338,6 @@ private ref SharedIndexEntryHeader Entry(int entryIndex)
return _region.Pointer + _layout.IndexOffset + ((long)entryIndex * _layout.IndexEntrySize) + _entryHeaderSize;
}
- private readonly record struct IndexEntrySnapshot(byte[] Key, ulong Hash, int SlotIndex, SlotLifecycleId LifecycleId);
}
internal readonly record struct IndexStateCounts(
diff --git a/src/SharedMemoryStore/Layout/StoreLayout.cs b/src/SharedMemoryStore/Layout/StoreLayout.cs
index 1ba2ddd..e0f4ac4 100644
--- a/src/SharedMemoryStore/Layout/StoreLayout.cs
+++ b/src/SharedMemoryStore/Layout/StoreLayout.cs
@@ -10,28 +10,31 @@ public StoreLayout(
int maxDescriptorBytes,
int maxKeyBytes)
{
- TotalBytes = totalBytes;
- SlotCount = slotCount;
- LeaseRecordCount = leaseRecordCount;
- MaxValueBytes = maxValueBytes;
- MaxDescriptorBytes = maxDescriptorBytes;
- MaxKeyBytes = maxKeyBytes;
- HeaderLength = Align(System.Runtime.InteropServices.Marshal.SizeOf());
- IndexEntryCount = NextPowerOfTwo(Math.Max(4, slotCount * 2));
- IndexEntrySize = Align(System.Runtime.InteropServices.Marshal.SizeOf() + maxKeyBytes);
- IndexOffset = HeaderLength;
- IndexLength = checked((long)IndexEntryCount * IndexEntrySize);
- LeaseRegistryOffset = Align(IndexOffset + IndexLength);
- LeaseRegistryLength = checked((long)leaseRecordCount * System.Runtime.InteropServices.Marshal.SizeOf());
- SlotMetadataOffset = Align(LeaseRegistryOffset + LeaseRegistryLength);
- SlotMetadataLength = checked((long)slotCount * System.Runtime.InteropServices.Marshal.SizeOf());
- DescriptorStride = Align(Math.Max(1, maxDescriptorBytes));
- DescriptorStorageOffset = Align(SlotMetadataOffset + SlotMetadataLength);
- DescriptorStorageLength = checked((long)slotCount * DescriptorStride);
- PayloadStride = Align(Math.Max(1, maxValueBytes));
- PayloadStorageOffset = Align(DescriptorStorageOffset + DescriptorStorageLength);
- PayloadStorageLength = checked((long)slotCount * PayloadStride);
- RequiredBytes = Align(PayloadStorageOffset + PayloadStorageLength);
+ checked
+ {
+ TotalBytes = totalBytes;
+ SlotCount = slotCount;
+ LeaseRecordCount = leaseRecordCount;
+ MaxValueBytes = maxValueBytes;
+ MaxDescriptorBytes = maxDescriptorBytes;
+ MaxKeyBytes = maxKeyBytes;
+ HeaderLength = Align(System.Runtime.InteropServices.Marshal.SizeOf());
+ IndexEntryCount = NextPowerOfTwo(Math.Max(4, slotCount * 2));
+ IndexEntrySize = Align(System.Runtime.InteropServices.Marshal.SizeOf() + maxKeyBytes);
+ IndexOffset = HeaderLength;
+ IndexLength = (long)IndexEntryCount * IndexEntrySize;
+ LeaseRegistryOffset = Align(IndexOffset + IndexLength);
+ LeaseRegistryLength = (long)leaseRecordCount * System.Runtime.InteropServices.Marshal.SizeOf();
+ SlotMetadataOffset = Align(LeaseRegistryOffset + LeaseRegistryLength);
+ SlotMetadataLength = (long)slotCount * System.Runtime.InteropServices.Marshal.SizeOf();
+ DescriptorStride = Align(Math.Max(1, maxDescriptorBytes));
+ DescriptorStorageOffset = Align(SlotMetadataOffset + SlotMetadataLength);
+ DescriptorStorageLength = (long)slotCount * DescriptorStride;
+ PayloadStride = Align(Math.Max(1, maxValueBytes));
+ PayloadStorageOffset = Align(DescriptorStorageOffset + DescriptorStorageLength);
+ PayloadStorageLength = (long)slotCount * PayloadStride;
+ RequiredBytes = Align(PayloadStorageOffset + PayloadStorageLength);
+ }
}
public long TotalBytes { get; }
@@ -64,6 +67,12 @@ public static long CalculateRequiredBytes(
int maxKeyBytes,
int leaseRecordCount)
{
+ ArgumentOutOfRangeException.ThrowIfLessThan(slotCount, 1);
+ ArgumentOutOfRangeException.ThrowIfLessThan(maxValueBytes, 1);
+ ArgumentOutOfRangeException.ThrowIfNegative(maxDescriptorBytes);
+ ArgumentOutOfRangeException.ThrowIfLessThan(maxKeyBytes, 1);
+ ArgumentOutOfRangeException.ThrowIfLessThan(leaseRecordCount, 1);
+
var layout = new StoreLayout(
0,
slotCount,
@@ -124,21 +133,26 @@ public bool FitsWithinTotalBytes()
return TotalBytes >= RequiredBytes
&& TotalBytes > 0
&& PayloadStorageOffset >= 0
- && PayloadStorageOffset + PayloadStorageLength <= TotalBytes;
+ && PayloadStorageLength >= 0;
}
public static int Align(int value)
{
- return (value + LayoutConstants.Alignment - 1) & ~(LayoutConstants.Alignment - 1);
+ return checked(value + LayoutConstants.Alignment - 1) & ~(LayoutConstants.Alignment - 1);
}
public static long Align(long value)
{
- return (value + LayoutConstants.Alignment - 1) & ~(LayoutConstants.Alignment - 1);
+ return checked(value + LayoutConstants.Alignment - 1) & ~(LayoutConstants.Alignment - 1);
}
private static int NextPowerOfTwo(int value)
{
+ if (value <= 0 || value > 1 << 30)
+ {
+ throw new OverflowException("The requested index entry count cannot be represented.");
+ }
+
var result = 1;
while (result < value)
{
diff --git a/src/SharedMemoryStore/MemoryStore.cs b/src/SharedMemoryStore/MemoryStore.cs
index 45459a6..ff01c10 100644
--- a/src/SharedMemoryStore/MemoryStore.cs
+++ b/src/SharedMemoryStore/MemoryStore.cs
@@ -1,4 +1,5 @@
using System.Buffers;
+using System.Diagnostics;
using System.Threading;
using SharedMemoryStore.Diagnostics;
using SharedMemoryStore.Ingest;
@@ -16,7 +17,7 @@ namespace SharedMemoryStore;
///
public sealed unsafe class MemoryStore : IDisposable
{
- private readonly object _gate = new();
+ private readonly SemaphoreSlim _gate = new(1, 1);
private readonly MemoryMappedStoreRegion _region;
private readonly ISharedStoreSynchronization _synchronization;
private readonly StoreLayout _layout;
@@ -87,7 +88,8 @@ public static StoreOpenStatus TryCreateOrOpen(
return validation;
}
- var mappingStatus = SharedStorePlatform.TryOpen(options, out var region, out var synchronization);
+ var waitStartTimestamp = Stopwatch.GetTimestamp();
+ var mappingStatus = SharedStorePlatform.TryOpen(options, waitOptions, out var region, out var synchronization);
if (mappingStatus != StoreOpenStatus.Success || region is null || synchronization is null)
{
return mappingStatus;
@@ -112,7 +114,7 @@ public static StoreOpenStatus TryCreateOrOpen(
}
StoreOpenStatus initializeStatus;
- if (!candidate.TryEnterStoreLock(waitOptions, out var lockStatus))
+ if (!candidate.TryEnterStoreLock(waitOptions.RemainingSince(waitStartTimestamp), out var lockStatus))
{
candidate.DisposeUninitialized();
return ToOpenStatus(lockStatus);
@@ -192,6 +194,7 @@ public StoreStatus TryPublish(
ref var slot = ref _slots.GetSlot(slotIndex);
var lifecycleId = SlotLifecycleId.FromSlot(slot);
+ var indexInserted = false;
try
{
_writer.Write(ref slot, value, descriptor);
@@ -200,6 +203,7 @@ public StoreStatus TryPublish(
_slots.Abort(slotIndex);
return Record(StoreStatus.DuplicateKey);
}
+ indexInserted = true;
var sequence = Interlocked.Increment(ref Header.Sequence);
_slots.Commit(slotIndex, hash, key.Length, descriptor.Length, value.Length, sequence);
@@ -207,6 +211,10 @@ public StoreStatus TryPublish(
}
catch (Exception)
{
+ if (indexInserted)
+ {
+ _index.TryRemoveSlot(slotIndex, lifecycleId, hash);
+ }
_slots.Abort(slotIndex);
return Record(StoreStatus.UnknownFailure);
}
@@ -302,7 +310,7 @@ public StoreStatus TryReserve(
}
catch (Exception)
{
- _index.TryRemoveSlot(slotIndex, lifecycleId);
+ _index.TryRemoveSlot(slotIndex, lifecycleId, hash);
_slots.Abort(slotIndex);
return Record(StoreStatus.UnknownFailure);
}
@@ -327,7 +335,7 @@ public StoreStatus TryPublishSegments(
}
///
- /// Publishes a segmented payload using the supplied wait policy for each synchronized reservation operation.
+ /// Publishes a segmented payload after acquiring shared synchronization with the supplied wait policy.
///
public StoreStatus TryPublishSegments(
ReadOnlySpan key,
@@ -336,8 +344,87 @@ public StoreStatus TryPublishSegments(
StoreWaitOptions waitOptions,
out long copiedBytes)
{
- var status = SegmentedPublisher.Publish(this, key, payload, descriptor, waitOptions, out copiedBytes);
- return status == StoreStatus.Success ? status : Record(status);
+ copiedBytes = 0;
+ if (payload.Length > int.MaxValue)
+ {
+ return Record(StoreStatus.ValueTooLarge);
+ }
+
+ if (!TryEnterOperation(waitOptions, out var operation, out var enterStatus))
+ {
+ return Record(enterStatus);
+ }
+
+ using (operation)
+ {
+ try
+ {
+ var ready = EnsureReady();
+ if (ready != StoreStatus.Success)
+ {
+ return Record(ready);
+ }
+
+ var validation = ValidateOperationInput(key, (int)payload.Length, descriptor);
+ if (validation != StoreStatus.Success)
+ {
+ return Record(validation);
+ }
+
+ var hash = StoreKey.Hash(key);
+ if (_index.TryFind(key, hash, out var existingSlotIndex, out _))
+ {
+ ref var existingSlot = ref _slots.GetSlot(existingSlotIndex);
+ if (Volatile.Read(ref existingSlot.State) is LayoutConstants.SlotPublished or LayoutConstants.SlotPublishing or LayoutConstants.SlotRemoveRequested)
+ {
+ return Record(StoreStatus.DuplicateKey);
+ }
+ }
+
+ if (!_slots.TryReserve(out var slotIndex))
+ {
+ return Record(StoreStatus.StoreFull);
+ }
+
+ ref var slot = ref _slots.GetSlot(slotIndex);
+ var lifecycleId = SlotLifecycleId.FromSlot(slot);
+ var indexInserted = false;
+ try
+ {
+ _writer.WriteDescriptor(ref slot, descriptor);
+ _writer.WriteSegments(ref slot, payload, out copiedBytes);
+ if (copiedBytes != payload.Length)
+ {
+ _slots.Abort(slotIndex);
+ return Record(StoreStatus.UnknownFailure);
+ }
+
+ if (!_index.TryInsert(key, hash, slotIndex, lifecycleId))
+ {
+ _slots.Abort(slotIndex);
+ return Record(StoreStatus.DuplicateKey);
+ }
+ indexInserted = true;
+
+ var sequence = Interlocked.Increment(ref Header.Sequence);
+ _slots.Commit(slotIndex, hash, key.Length, descriptor.Length, (int)payload.Length, sequence);
+ return StoreStatus.Success;
+ }
+ catch (Exception)
+ {
+ if (indexInserted)
+ {
+ _index.TryRemoveSlot(slotIndex, lifecycleId, hash);
+ }
+ _slots.Abort(slotIndex);
+ return Record(StoreStatus.UnknownFailure);
+ }
+ }
+ finally
+ {
+ ExitStoreLock();
+ }
+ }
}
///
@@ -1039,13 +1126,13 @@ internal StoreStatus AbortReservation(
return Record(ready);
}
- var status = _slots.ValidatePendingReservation(slotIndex, lifecycleId, out _);
+ var status = _slots.ValidatePendingReservation(slotIndex, lifecycleId, out var slot);
if (status != StoreStatus.Success)
{
return Record(status);
}
- if (!_index.TryRemoveSlot(slotIndex, lifecycleId))
+ if (!_index.TryRemoveSlot(slotIndex, lifecycleId, slot.KeyHash))
{
return Record(StoreStatus.CorruptStore);
}
@@ -1141,6 +1228,11 @@ private void ClearRegion(long length)
}
private StoreStatus ValidateOperationInput(ReadOnlySpan key, ReadOnlySpan value, ReadOnlySpan descriptor)
+ {
+ return ValidateOperationInput(key, value.Length, descriptor);
+ }
+
+ private StoreStatus ValidateOperationInput(ReadOnlySpan key, int valueLength, ReadOnlySpan descriptor)
{
var keyStatus = StoreKey.Validate(key, _layout.MaxKeyBytes);
if (keyStatus != StoreStatus.Success)
@@ -1148,7 +1240,7 @@ private StoreStatus ValidateOperationInput(ReadOnlySpan key, ReadOnlySpan<
return keyStatus;
}
- if (value.Length > _layout.MaxValueBytes)
+ if (valueLength > _layout.MaxValueBytes)
{
return StoreStatus.ValueTooLarge;
}
@@ -1211,18 +1303,39 @@ private bool TryEnterStoreLock(StoreWaitOptions waitOptions, out StoreStatus sta
return false;
}
- Monitor.Enter(_gate);
+ var waitStartTimestamp = Stopwatch.GetTimestamp();
+ bool gateEntered;
+ try
+ {
+ gateEntered = waitOptions.IsInfinite
+ ? _gate.Wait(Timeout.InfiniteTimeSpan, waitOptions.CancellationToken)
+ : _gate.Wait(waitOptions.Timeout, waitOptions.CancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ status = StoreStatus.OperationCanceled;
+ return false;
+ }
+
+ if (!gateEntered)
+ {
+ status = waitOptions.CancellationToken.IsCancellationRequested
+ ? StoreStatus.OperationCanceled
+ : StoreStatus.StoreBusy;
+ return false;
+ }
+
if (_lifecycle.IsDisposingOrDisposed || _disposed)
{
- Monitor.Exit(_gate);
+ _gate.Release();
status = StoreStatus.StoreDisposed;
return false;
}
- status = _synchronization.TryEnter(waitOptions);
+ status = _synchronization.TryEnter(waitOptions.RemainingSince(waitStartTimestamp));
if (status != StoreStatus.Success)
{
- Monitor.Exit(_gate);
+ _gate.Release();
return false;
}
@@ -1295,6 +1408,11 @@ private StoreStatus EnsureReady()
private StoreStatus Record(StoreStatus status)
{
+ if (status == StoreStatus.CorruptStore && !_disposed)
+ {
+ Volatile.Write(ref Header.StoreState, LayoutConstants.StoreCorrupt);
+ }
+
_diagnostics.Record(status);
return status;
}
@@ -1332,6 +1450,6 @@ private static bool ValidateSectionBounds(in StoreHeader header)
private void ExitStoreLock()
{
_synchronization.Exit();
- Monitor.Exit(_gate);
+ _gate.Release();
}
}
diff --git a/src/SharedMemoryStore/Options/StoreOptionsValidationResult.cs b/src/SharedMemoryStore/Options/StoreOptionsValidationResult.cs
index bb5eb98..c51fb8c 100644
--- a/src/SharedMemoryStore/Options/StoreOptionsValidationResult.cs
+++ b/src/SharedMemoryStore/Options/StoreOptionsValidationResult.cs
@@ -15,7 +15,7 @@ public sealed class StoreOptionsValidationResult
internal StoreOptionsValidationResult(StoreOpenStatus status, IReadOnlyList failures)
{
Status = status;
- Failures = failures;
+ Failures = Array.AsReadOnly(failures.ToArray());
}
/// Gets a value indicating whether the options are valid.
diff --git a/src/SharedMemoryStore/SharedMemoryStore.csproj b/src/SharedMemoryStore/SharedMemoryStore.csproj
index 0372052..c767a13 100644
--- a/src/SharedMemoryStore/SharedMemoryStore.csproj
+++ b/src/SharedMemoryStore/SharedMemoryStore.csproj
@@ -9,15 +9,16 @@
true
true
SharedMemoryStore
- 1.0.0
+ 1.0.1
SharedMemoryStore contributors
SharedMemoryStore
A bounded named shared-memory key-value store for opaque binary values.
shared-memory;memory-mapped-file;zero-copy;linux;windows;docker;library
MIT
git
+ https://github.com/rantri/SharedMemoryStore
README.md
- Linux, Windows, and same-host Docker support: adds platform resource adapters, Docker validation, portable development workflows, and updated platform documentation while preserving the 1.0.0 public API contract.
+ Linux, Windows, and same-host Docker support hardening: fixes bounded waits, crash-safe ownership and index maintenance, private Linux resource permissions, layout overflow validation, and cleanup reliability while preserving the 1.0.0 public API and layout.
SharedMemoryStore
SharedMemoryStore
diff --git a/src/SharedMemoryStore/Slots/SlotReclaimer.cs b/src/SharedMemoryStore/Slots/SlotReclaimer.cs
index 704f9fe..309fce6 100644
--- a/src/SharedMemoryStore/Slots/SlotReclaimer.cs
+++ b/src/SharedMemoryStore/Slots/SlotReclaimer.cs
@@ -35,7 +35,11 @@ public StoreStatus RequestRemove(int slotIndex, SlotLifecycleId lifecycleId)
return StoreStatus.RemovePending;
}
- _index.TryRemoveSlot(slotIndex, lifecycleId);
+ if (!_index.TryRemoveSlot(slotIndex, lifecycleId, slot.KeyHash))
+ {
+ return StoreStatus.CorruptStore;
+ }
+
return _slots.Reclaim(slotIndex);
}
@@ -50,7 +54,11 @@ public StoreStatus ReclaimAfterFinalRelease(int slotIndex, SlotLifecycleId lifec
if (Volatile.Read(ref slot.State) == LayoutConstants.SlotRemoveRequested
&& Volatile.Read(ref slot.UsageCount) == 0)
{
- _index.TryRemoveSlot(slotIndex, lifecycleId);
+ if (!_index.TryRemoveSlot(slotIndex, lifecycleId, slot.KeyHash))
+ {
+ return StoreStatus.CorruptStore;
+ }
+
return _slots.Reclaim(slotIndex);
}
diff --git a/src/SharedMemoryStore/Slots/SlotWriter.cs b/src/SharedMemoryStore/Slots/SlotWriter.cs
index 592925e..61f4264 100644
--- a/src/SharedMemoryStore/Slots/SlotWriter.cs
+++ b/src/SharedMemoryStore/Slots/SlotWriter.cs
@@ -1,3 +1,4 @@
+using System.Buffers;
using SharedMemoryStore.Interop;
using SharedMemoryStore.Layout;
@@ -25,4 +26,22 @@ public void WriteDescriptor(ref SharedSlotMetadata slot, ReadOnlySpan desc
var descriptorTarget = new Span(_region.Pointer + slot.DescriptorOffset, descriptor.Length);
descriptor.CopyTo(descriptorTarget);
}
+
+ public void WriteSegments(ref SharedSlotMetadata slot, in ReadOnlySequence payload, out long copiedBytes)
+ {
+ copiedBytes = 0;
+ foreach (var segment in payload)
+ {
+ if (segment.Length > payload.Length - copiedBytes)
+ {
+ throw new InvalidDataException("The segmented payload exceeded its announced sequence length.");
+ }
+
+ var destination = new Span(
+ _region.Pointer + slot.PayloadOffset + copiedBytes,
+ segment.Length);
+ segment.Span.CopyTo(destination);
+ copiedBytes += segment.Length;
+ }
+ }
}
diff --git a/src/SharedMemoryStore/StoreWaitOptions.cs b/src/SharedMemoryStore/StoreWaitOptions.cs
index 121ad41..6701be5 100644
--- a/src/SharedMemoryStore/StoreWaitOptions.cs
+++ b/src/SharedMemoryStore/StoreWaitOptions.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics;
using System.Threading;
namespace SharedMemoryStore;
@@ -26,4 +27,17 @@ public readonly record struct StoreWaitOptions(TimeSpan Timeout, CancellationTok
/// Gets a value indicating whether the timeout is finite and non-negative, or explicitly infinite.
public bool IsValid => IsInfinite || Timeout >= TimeSpan.Zero;
+
+ internal StoreWaitOptions RemainingSince(long startTimestamp)
+ {
+ if (IsInfinite)
+ {
+ return this;
+ }
+
+ var remaining = Timeout - Stopwatch.GetElapsedTime(startTimestamp);
+ return new StoreWaitOptions(
+ remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero,
+ CancellationToken);
+ }
}
diff --git a/tests/SharedMemoryStore.UnitTests/CorruptStoreTests.cs b/tests/SharedMemoryStore.UnitTests/CorruptStoreTests.cs
index ed662bd..9bc3ceb 100644
--- a/tests/SharedMemoryStore.UnitTests/CorruptStoreTests.cs
+++ b/tests/SharedMemoryStore.UnitTests/CorruptStoreTests.cs
@@ -16,4 +16,19 @@ public void OperationsReturnCorruptStatusAfterSafeErrorMode()
Assert.Equal(StoreStatus.CorruptStore, store.TryAcquire([1], out _));
Assert.Equal(StoreStatus.CorruptStore, store.TryRemove([1]));
}
+
+ [Fact]
+ public void DetectedUsageUnderflowMovesStoreIntoSafeErrorMode()
+ {
+ using var store = StoreTestNames.CreateStore(StoreTestNames.Options());
+ Assert.Equal(StoreStatus.Success, store.TryPublish([1], [1]));
+ Assert.Equal(StoreStatus.Success, store.TryAcquire([1], out var lease));
+
+ ref var slot = ref store.GetSlotForTesting(lease.SlotIndexForTesting);
+ slot.UsageCount = 0;
+
+ Assert.Equal(StoreStatus.CorruptStore, lease.Release());
+ Assert.Equal(LayoutConstants.StoreCorrupt, store.Header.StoreState);
+ Assert.Equal(StoreStatus.CorruptStore, store.TryPublish([2], [2]));
+ }
}
diff --git a/tests/SharedMemoryStore.UnitTests/PlatformResourceNameTests.cs b/tests/SharedMemoryStore.UnitTests/PlatformResourceNameTests.cs
index db8850e..1f63c53 100644
--- a/tests/SharedMemoryStore.UnitTests/PlatformResourceNameTests.cs
+++ b/tests/SharedMemoryStore.UnitTests/PlatformResourceNameTests.cs
@@ -1,4 +1,6 @@
+using System.Runtime.Versioning;
using SharedMemoryStore.Interop;
+using SharedMemoryStore.UnitTests.TestSupport;
namespace SharedMemoryStore.UnitTests;
@@ -36,4 +38,65 @@ public void WindowsRegionNamePreservesPublicNameForCompatibility()
Assert.Equal("sms.compatibility", resourceName.WindowsRegionName);
Assert.StartsWith(@"Local\SharedMemoryStore-", resourceName.WindowsSynchronizationName, StringComparison.Ordinal);
}
+
+ [Fact]
+ public void WindowsGlobalMappingsUseGlobalSynchronizationScope()
+ {
+ var resourceName = PlatformResourceName.Create(@"Global\sms.compatibility");
+
+ Assert.Equal(@"Global\sms.compatibility", resourceName.WindowsRegionName);
+ Assert.StartsWith(@"Global\SharedMemoryStore-", resourceName.WindowsSynchronizationName, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void LinuxResourcesArePrivateToTheCreatingIdentity()
+ {
+ if (!OperatingSystem.IsLinux())
+ {
+ return;
+ }
+
+ var options = StoreTestNames.Options();
+ using var store = StoreTestNames.CreateStore(options);
+ var resource = PlatformResourceName.Create(options.Name);
+ var privateFileMode = UnixFileMode.UserRead | UnixFileMode.UserWrite;
+ var privateDirectoryMode = privateFileMode | UnixFileMode.UserExecute;
+
+ Assert.Equal(privateDirectoryMode, File.GetUnixFileMode(Path.GetDirectoryName(resource.LinuxRegionPath)!));
+ Assert.Equal(privateFileMode, File.GetUnixFileMode(resource.LinuxRegionPath));
+ Assert.Equal(privateFileMode, File.GetUnixFileMode(resource.LinuxSynchronizationPath));
+ Assert.Equal(privateFileMode, File.GetUnixFileMode(resource.LinuxOwnersPath));
+ Assert.Equal(privateFileMode, File.GetUnixFileMode(resource.LinuxLifecycleLockPath));
+ }
+
+ [Fact]
+ [SupportedOSPlatform("linux")]
+ public void LinuxResourceDirectoryRejectsSymbolicLinks()
+ {
+ if (!OperatingSystem.IsLinux())
+ {
+ return;
+ }
+
+ var root = Path.Combine(Path.GetTempPath(), "sms-symlink-test-" + Guid.NewGuid().ToString("N"));
+ var target = Path.Combine(root, "target");
+ var link = Path.Combine(root, "link");
+ Directory.CreateDirectory(target);
+ File.SetUnixFileMode(target, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute
+ | UnixFileMode.GroupRead | UnixFileMode.GroupExecute
+ | UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
+ Directory.CreateSymbolicLink(link, target);
+
+ try
+ {
+ Assert.Throws(() => LinuxSharedMemoryDirectory.EnsureExists(link));
+ Assert.NotEqual(LinuxSharedMemoryDirectory.PrivateDirectoryMode, File.GetUnixFileMode(target));
+ }
+ finally
+ {
+ Directory.Delete(link);
+ Directory.Delete(target);
+ Directory.Delete(root);
+ }
+ }
}
diff --git a/tests/SharedMemoryStore.UnitTests/SegmentedPublishTests.cs b/tests/SharedMemoryStore.UnitTests/SegmentedPublishTests.cs
index e56b6cb..f5e518e 100644
--- a/tests/SharedMemoryStore.UnitTests/SegmentedPublishTests.cs
+++ b/tests/SharedMemoryStore.UnitTests/SegmentedPublishTests.cs
@@ -30,6 +30,22 @@ public void PublishesOneSegmentAndManySegments()
lease.Dispose();
}
+ [Fact]
+ public void InconsistentSequenceLengthCannotWriteBeyondReservedPayload()
+ {
+ using var store = StoreTestNames.CreateStore(StoreTestNames.Options(slotCount: 1, maxValueBytes: 64));
+ var first = new BufferSegment(new byte[8]);
+ var last = first.Append(new byte[8], runningIndex: 1);
+ var malformed = new ReadOnlySequence(first, 0, last, last.Memory.Length);
+
+ var status = store.TryPublishSegments([1], malformed, default, out var copiedBytes);
+
+ Assert.Equal(StoreStatus.UnknownFailure, status);
+ Assert.Equal(8, copiedBytes);
+ Assert.Equal(StoreStatus.NotFound, store.TryAcquire([1], out _));
+ Assert.Equal(1, store.GetDiagnostics().FreeSlotCount);
+ }
+
private static class SequenceFactory
{
public static ReadOnlySequence Create(params byte[][] segments)
@@ -52,11 +68,11 @@ public BufferSegment(byte[] memory)
Memory = memory;
}
- public BufferSegment Append(byte[] memory)
+ public BufferSegment Append(byte[] memory, long? runningIndex = null)
{
var segment = new BufferSegment(memory)
{
- RunningIndex = RunningIndex + Memory.Length
+ RunningIndex = runningIndex ?? RunningIndex + Memory.Length
};
Next = segment;
return segment;
diff --git a/tests/SharedMemoryStore.UnitTests/StoreOptionsValidationTests.cs b/tests/SharedMemoryStore.UnitTests/StoreOptionsValidationTests.cs
index bd4e7c0..c1fcc90 100644
--- a/tests/SharedMemoryStore.UnitTests/StoreOptionsValidationTests.cs
+++ b/tests/SharedMemoryStore.UnitTests/StoreOptionsValidationTests.cs
@@ -64,4 +64,43 @@ public void TooSmallTotalBytesReturnsInsufficientCapacityDetail()
Assert.Equal(StoreOpenStatus.InsufficientCapacity, result.Status);
Assert.Contains(result.Failures, failure => failure.MemberName == nameof(SharedMemoryStoreOptions.TotalBytes));
}
+
+ [Fact]
+ public void LayoutCalculationRejectsDimensionsThatOverflowInternalIndexing()
+ {
+ Assert.Throws(() => SharedMemoryStoreOptions.CalculateRequiredBytes(
+ int.MaxValue,
+ maxValueBytes: 1,
+ maxDescriptorBytes: 0,
+ maxKeyBytes: 1,
+ leaseRecordCount: 1));
+
+ Assert.Throws(() => SharedMemoryStoreOptions.CalculateRequiredBytes(
+ slotCount: 1,
+ maxValueBytes: int.MaxValue,
+ maxDescriptorBytes: 0,
+ maxKeyBytes: 1,
+ leaseRecordCount: 1));
+ }
+
+ [Fact]
+ public void ValidateRejectsDimensionsThatCannotBeRepresentedByTheLayout()
+ {
+ var options = new SharedMemoryStoreOptions
+ {
+ Name = StoreTestNames.Create(),
+ SlotCount = int.MaxValue,
+ MaxValueBytes = 1,
+ MaxDescriptorBytes = 0,
+ MaxKeyBytes = 1,
+ LeaseRecordCount = 1,
+ TotalBytes = long.MaxValue
+ };
+
+ var result = options.Validate();
+
+ Assert.False(result.IsValid);
+ Assert.Equal(StoreOpenStatus.InvalidOptions, result.Status);
+ Assert.Contains(result.Failures, failure => failure.MemberName == nameof(SharedMemoryStoreOptions.TotalBytes));
+ }
}
diff --git a/tests/SharedMemoryStore.UnitTests/StoreWaitPolicyTests.cs b/tests/SharedMemoryStore.UnitTests/StoreWaitPolicyTests.cs
index 7ac2544..67c1fb8 100644
--- a/tests/SharedMemoryStore.UnitTests/StoreWaitPolicyTests.cs
+++ b/tests/SharedMemoryStore.UnitTests/StoreWaitPolicyTests.cs
@@ -40,6 +40,52 @@ public void NoWaitPublishReturnsBusyWhenSharedMutexIsHeld()
Assert.True(elapsed.Elapsed <= TimeSpan.FromMilliseconds(250));
}
+ [Fact]
+ public void NoWaitUsesOneBudgetAcrossLocalAndSharedContention()
+ {
+ var options = StoreTestNames.Options();
+ using var store = StoreTestNames.CreateStore(options);
+ using var synchronization = PlatformTestEnvironment.HoldStoreSynchronization(options.Name);
+ using var blockingStarted = new ManualResetEventSlim();
+ using var blockingDone = new ManualResetEventSlim();
+ using var noWaitDone = new ManualResetEventSlim();
+
+ StoreStatus blockingResult = default;
+ StoreStatus noWaitResult = default;
+ var blockingThread = new Thread(() =>
+ {
+ blockingStarted.Set();
+ blockingResult = store.TryPublish(
+ [1],
+ [1],
+ default,
+ new StoreWaitOptions(TimeSpan.FromSeconds(1)));
+ blockingDone.Set();
+ });
+ blockingThread.Start();
+
+ Assert.True(blockingStarted.Wait(TimeSpan.FromSeconds(1)));
+ Thread.Sleep(50);
+
+ var elapsed = Stopwatch.StartNew();
+ var noWaitThread = new Thread(() =>
+ {
+ noWaitResult = store.TryPublish([2], [2], default, StoreWaitOptions.NoWait);
+ noWaitDone.Set();
+ });
+ noWaitThread.Start();
+
+ var completedWithinBudget = noWaitDone.Wait(TimeSpan.FromMilliseconds(250));
+ Assert.True(blockingDone.Wait(TimeSpan.FromSeconds(2)));
+ Assert.True(noWaitDone.Wait(TimeSpan.FromSeconds(1)));
+ blockingThread.Join();
+ noWaitThread.Join();
+
+ Assert.True(completedWithinBudget, $"No-wait operation took {elapsed.Elapsed} while the same handle was contended.");
+ Assert.Equal(StoreStatus.StoreBusy, blockingResult);
+ Assert.Equal(StoreStatus.StoreBusy, noWaitResult);
+ }
+
[Fact]
public void CanceledAcquireReturnsOperationCanceledBeforeSynchronization()
{
@@ -75,4 +121,37 @@ public void TryGetDiagnosticsReturnsBusyWhenSharedMutexIsHeld()
Assert.True(done.Wait(TimeSpan.FromSeconds(1)));
Assert.Equal(StoreStatus.StoreBusy, result);
}
+
+ [Fact]
+ public void LinuxOpenNoWaitIncludesLifecycleSynchronization()
+ {
+ if (!OperatingSystem.IsLinux())
+ {
+ return;
+ }
+
+ var options = StoreTestNames.Options();
+ var resourceName = PlatformTestEnvironment.ResourceNameFor(options.Name);
+ var lockStatus = SharedMemoryStore.Interop.LinuxFileLock.TryAcquire(
+ resourceName.LinuxLifecycleLockPath,
+ StoreWaitOptions.Infinite,
+ out var lifecycleLock);
+ Assert.Equal(StoreStatus.Success, lockStatus);
+ using var heldLock = Assert.IsType(lifecycleLock);
+ using var done = new ManualResetEventSlim();
+
+ StoreOpenStatus result = default;
+ var elapsed = Stopwatch.StartNew();
+ var thread = new Thread(() =>
+ {
+ result = MemoryStore.TryCreateOrOpen(options, StoreWaitOptions.NoWait, out _);
+ done.Set();
+ });
+ thread.Start();
+
+ Assert.True(done.Wait(TimeSpan.FromMilliseconds(250)));
+ thread.Join();
+ Assert.Equal(StoreOpenStatus.StoreBusy, result);
+ Assert.True(elapsed.Elapsed <= TimeSpan.FromMilliseconds(250));
+ }
}