From 3883da069a300d79851ba391a3a19b5dd18458dc Mon Sep 17 00:00:00 2001 From: Ran Trifon Date: Fri, 10 Jul 2026 09:11:56 +0300 Subject: [PATCH] Harden production reliability and release readiness --- .github/workflows/ci.yml | 46 +++++ CHANGELOG.md | 31 ++++ Directory.Build.props | 7 + README.md | 6 +- SECURITY.md | 21 ++- SUPPORT.md | 4 +- docs/getting-started.md | 2 +- docs/lifecycle.md | 5 + docs/packaging.md | 5 +- docs/portability.md | 10 +- docs/releases.md | 23 ++- docs/usage.md | 9 +- scripts/validate-docker-shared-memory.ps1 | 2 +- scripts/validate-docs.ps1 | 5 +- .../contracts/reservation-api.md | 12 +- .../Ingest/ReservationMemoryManager.cs | 25 ++- .../Ingest/ReservationRecovery.cs | 2 +- .../Ingest/SegmentedPublisher.cs | 69 ------- .../Interop/LinuxFileLock.cs | 90 +++++++-- .../Interop/LinuxSharedMemoryRegion.cs | 173 +++++++++++++++--- .../Interop/MemoryMappedStoreRegion.cs | 2 +- .../Interop/PlatformResourceName.cs | 25 ++- .../Interop/SharedStorePlatform.cs | 6 +- .../Interop/WindowsSharedMemoryRegion.cs | 15 +- .../Layout/SharedKeyIndex.cs | 143 +++++++++------ src/SharedMemoryStore/Layout/StoreLayout.cs | 64 ++++--- src/SharedMemoryStore/MemoryStore.cs | 148 +++++++++++++-- .../Options/StoreOptionsValidationResult.cs | 2 +- .../SharedMemoryStore.csproj | 5 +- src/SharedMemoryStore/Slots/SlotReclaimer.cs | 12 +- src/SharedMemoryStore/Slots/SlotWriter.cs | 19 ++ src/SharedMemoryStore/StoreWaitOptions.cs | 14 ++ .../CorruptStoreTests.cs | 15 ++ .../PlatformResourceNameTests.cs | 63 +++++++ .../SegmentedPublishTests.cs | 20 +- .../StoreOptionsValidationTests.cs | 39 ++++ .../StoreWaitPolicyTests.cs | 79 ++++++++ 37 files changed, 952 insertions(+), 266 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 Directory.Build.props delete mode 100644 src/SharedMemoryStore/Ingest/SegmentedPublisher.cs 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)); + } }