Skip to content

Commit 34941f2

Browse files
committed
Fix capacity race on .NET Framework
1 parent d6ce2af commit 34941f2

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
@@ -839,6 +839,53 @@ private SharedMapping(MemoryMappedFile? mmf, Chunk[] chunks, long length)
839839
}
840840

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

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

0 commit comments

Comments
 (0)