Skip to content

Commit 68f2872

Browse files
paulirwinmarionoack
authored andcommitted
Fix capacity race on .NET Framework
1 parent ec1def6 commit 68f2872

2 files changed

Lines changed: 95 additions & 35 deletions

File tree

src/Lucene.Net.Tests/Store/TestMultiMMap.cs

Lines changed: 38 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -498,14 +498,31 @@ private void AssertChunking(Random random, int chunkSize)
498498
// extends a file on disk while the foreground thread repeatedly
499499
// opens it with MMapDirectory.OpenInput. The original failure
500500
// mode was ArgumentOutOfRangeException(paramName="capacity")
501-
// from MemoryMappedFile.CreateFromFile, because we were passing
502-
// a caller-computed capacity (fc.Length) that could be smaller
503-
// than the actual file size by the time the framework did its
504-
// internal stat. The current design passes capacity: 0, which
505-
// tells the framework to size the mapping from the file's
506-
// current on-disk length atomically — there is no caller-side
507-
// capacity to disagree with the file. This test asserts that
508-
// OpenInput continues to succeed under concurrent file extension.
501+
// from MemoryMappedFile.CreateFromFile, because the on-disk file
502+
// size could exceed our caller-computed capacity by the time the
503+
// framework did its internal stat. .NET Framework's
504+
// CreateFromFile reads fileStream.Length multiple times
505+
// non-atomically (referencesource MemoryMappedFile.cs L192-L243);
506+
// modern .NET snapshots it into a single local
507+
// (dotnet/runtime MemoryMappedFile.cs L237-L268). Even when we
508+
// pass capacity: 0 the .NET Framework path still races because
509+
// the length is re-read for both the defaulting step and the
510+
// capacity-vs-size guard. SharedMapping.Create handles the
511+
// residual race with a retry loop. This test asserts that
512+
// OpenInput continues to succeed under concurrent file
513+
// extension.
514+
//
515+
// Test design notes:
516+
// - The writer extends only (never truncates). Truncating a
517+
// user-mapped file on Windows fails with ERROR_USER_MAPPED_FILE
518+
// and is unrelated to what we're verifying here.
519+
// - The reader runs a bounded number of iterations rather than a
520+
// wall-clock loop. Sustained mmap churn (thousands of
521+
// map/unmap pairs per second) can transiently exhaust Windows
522+
// kernel resources (ERROR_NO_SYSTEM_RESOURCES,
523+
// ERROR_ACCESS_DENIED on view creation), which is also
524+
// unrelated to the capacity race. A few hundred iterations
525+
// are plenty to repeatedly hit the race window.
509526
[Test, LuceneNetSpecific, Slow]
510527
public void TestOpenInputConcurrentFileExtension_Issue1090()
511528
{
@@ -518,7 +535,7 @@ public void TestOpenInputConcurrentFileExtension_Issue1090()
518535

519536
using var mmapDir = new MMapDirectory(dir);
520537

521-
const long maxFileSize = 1L * 1024 * 1024; // 1 MiB cap
538+
const long maxFileSize = 64L * 1024 * 1024; // 64 MiB safety cap
522539
var stop = new ManualResetEventSlim(false);
523540
Exception writerError = null;
524541

@@ -531,15 +548,14 @@ public void TestOpenInputConcurrentFileExtension_Issue1090()
531548
{
532549
using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Write, FileShare.ReadWrite);
533550
fs.Seek(0, SeekOrigin.End);
534-
if (fs.Length < maxFileSize)
535-
{
536-
fs.Write(chunk, 0, chunk.Length);
537-
}
538-
else
551+
if (fs.Length >= maxFileSize)
539552
{
540-
// Keep the file bounded: truncate back and grow again.
541-
fs.SetLength(64);
553+
// Stop extending if we somehow reach the cap. The
554+
// reader's bounded iteration count guarantees this
555+
// is far above what we'll hit in a normal run.
556+
break;
542557
}
558+
fs.Write(chunk, 0, chunk.Length);
543559
}
544560
}
545561
catch (Exception e)
@@ -552,23 +568,17 @@ public void TestOpenInputConcurrentFileExtension_Issue1090()
552568

553569
try
554570
{
555-
var sw = Stopwatch.StartNew();
556-
int iterations = 0;
557-
// Stress OpenInput while the background thread extends/truncates the
558-
// file. Each call must succeed cleanly. A short window is plenty:
559-
// before the capacity:0 fix this race fired in well under a second.
560-
const int maxSeconds = 5;
561-
while (sw.Elapsed < TimeSpan.FromSeconds(maxSeconds))
571+
// Bounded iteration count keeps mmap churn well below the
572+
// Windows kernel-resource threshold while still exercising
573+
// the capacity race many times over.
574+
const int iterations = 500;
575+
for (int i = 0; i < iterations; i++)
562576
{
563577
using (var _ = mmapDir.OpenInput(name, NewIOContext(Random)))
564578
{
565-
// Just open and dispose; the race occurred during construction.
579+
// Just open and dispose; the race occurs during construction.
566580
}
567-
iterations++;
568581
}
569-
570-
TestContext.Progress.WriteLine(
571-
$"TestOpenInputConcurrentFileExtension: completed {iterations} OpenInput calls in {sw.Elapsed.TotalSeconds:F1}s");
572582
}
573583
finally
574584
{

src/Lucene.Net/Store/MMapDirectory.cs

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -840,6 +840,53 @@ private SharedMapping(MemoryMappedFile? mmf, Chunk[] chunks, long length)
840840
}
841841

842842
internal static SharedMapping Create(string file, int chunkSizePower)
843+
{
844+
// .NET Framework's MemoryMappedFile.CreateFromFile reads
845+
// fileStream.Length multiple times non-atomically: once
846+
// to materialize a default capacity (when 0 is passed),
847+
// then again to enforce `fileStream.Length <= capacity`.
848+
// A concurrent extender that grows the file between
849+
// those reads trips an ArgumentOutOfRangeException
850+
// ("capacity") with message "The capacity may not be
851+
// smaller than the file size." See referencesource
852+
// System.Core/System/IO/MemoryMappedFiles/
853+
// MemoryMappedFile.cs lines 192-243:
854+
// https://github.com/microsoft/referencesource/blob/ec9fa9ae770d522a5b5f0607898044b7478574a3/System.Core/System/IO/MemoryMappedFiles/MemoryMappedFile.cs#L192-L243
855+
//
856+
// Modern .NET (dotnet/runtime) caches the length into a
857+
// single local fileSize at the top of CreateFromFile
858+
// and reuses it for both the defaulting step and the
859+
// VerifyMemoryMappedFileAccess guard, so the race
860+
// cannot fire and this loop runs once:
861+
// https://github.com/dotnet/runtime/blob/550500a978b784658a04110d49b3335dcacf33e0/src/libraries/System.IO.MemoryMappedFiles/src/System/IO/MemoryMappedFiles/MemoryMappedFile.cs#L237-L268
862+
// https://github.com/dotnet/runtime/blob/550500a978b784658a04110d49b3335dcacf33e0/src/libraries/System.IO.MemoryMappedFiles/src/System/IO/MemoryMappedFiles/MemoryMappedFile.Windows.cs#L14-L26
863+
//
864+
// The retry budget is generous because the race window
865+
// is small but the retry is cheap (a FileStream reopen
866+
// plus another CreateFromFile call), and a tight
867+
// extender can keep losing the race for many attempts
868+
// in a row. Yield between attempts so the extender
869+
// thread can make progress and reach a stable point
870+
// between writes. (#1090)
871+
const int maxAttempts = 32;
872+
for (int attempt = 0; ; attempt++)
873+
{
874+
try
875+
{
876+
return CreateAttempt(file, chunkSizePower);
877+
}
878+
catch (ArgumentOutOfRangeException e)
879+
when (e.ParamName == "capacity" && attempt < maxAttempts - 1)
880+
{
881+
// Re-open and retry. The FileStream from the failed
882+
// attempt was disposed by CreateFromFile (leaveOpen:
883+
// false) before the exception propagated.
884+
Thread.Yield();
885+
}
886+
}
887+
}
888+
889+
private static SharedMapping CreateAttempt(string file, int chunkSizePower)
843890
{
844891
// We open our own FileStream so we control the FileShare
845892
// flags. The path-based CreateFromFile overload internally
@@ -879,13 +926,16 @@ internal static SharedMapping Create(string file, int chunkSizePower)
879926
return new SharedMapping(mmf: null, chunks: Array.Empty<Chunk>(), length: 0);
880927
}
881928

882-
// capacity: 0 -> the framework uses the file's
883-
// current size on disk, atomically with mapping
884-
// creation. This eliminates the #1090 race window
885-
// we previously had to retry around.
886-
// leaveOpen: false -> the MMF takes ownership of the
887-
// FileStream and disposes it on its own Dispose, so
888-
// we don't need to track it ourselves.
929+
// capacity: 0 -> the framework sizes the mapping
930+
// from the file's current length. On modern .NET
931+
// that length is captured into a single local and
932+
// reused, so there is no race; on .NET Framework
933+
// the length is re-read across the defaulting and
934+
// validation steps, which is why Create wraps this
935+
// call in a retry loop.
936+
// leaveOpen: false -> the MMF takes ownership of
937+
// the FileStream and disposes it on its own
938+
// Dispose, so we don't need to track it ourselves.
889939
mmf = MemoryMappedFile.CreateFromFile(
890940
fileStream: fs,
891941
mapName: null,

0 commit comments

Comments
 (0)