Skip to content

Commit 4513b08

Browse files
committed
Fix Copilot's PR feedback
1 parent 94cc141 commit 4513b08

3 files changed

Lines changed: 179 additions & 52 deletions

File tree

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

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -547,7 +547,10 @@ public void TestOpenInputConcurrentFileExtension_Issue1090()
547547
// promptly after the root is disposed. A successful pass here is
548548
// therefore a *positive* result — not Inconclusive — because the
549549
// expected behavior is that the invariant holds throughout.
550-
[Test, LuceneNetSpecific, Slow, NonParallelizable]
550+
// [Nightly]: wall-clock stress loop (up to ~30s). Kept out of the
551+
// default run so CI isn't lengthened, but exercised in nightly runs
552+
// where catching regressions in the #1013 race path is worth the time.
553+
[Test, LuceneNetSpecific, Slow, Nightly, NonParallelizable]
551554
public void TestConcurrentCloneReadVsDispose_Issue1013()
552555
{
553556
var dirPath = CreateTempDir("testIssue1013");
@@ -688,7 +691,8 @@ public void TestConcurrentCloneReadVsDispose_Issue1013()
688691
// - After join, calling Clone() + read on the disposed master
689692
// from the main thread throws AlreadyClosed — pinning that a
690693
// disposed root cannot silently hand out a working clone.
691-
[Test, LuceneNetSpecific, Slow, NonParallelizable]
694+
// [Nightly]: wall-clock stress loop (~15s). See Issue1013 rationale.
695+
[Test, LuceneNetSpecific, Slow, Nightly, NonParallelizable]
692696
public void TestConcurrentCloneVsDispose_RaceScenario()
693697
{
694698
var dirPath = CreateTempDir("testCloneVsDispose");
@@ -780,7 +784,8 @@ public void TestConcurrentCloneVsDispose_RaceScenario()
780784
// Concurrent read of the SAME instance during Dispose of that
781785
// instance. Drain-barrier must prevent the disposer from releasing
782786
// the pointer while a reader is mid-CopyBlockUnaligned.
783-
[Test, LuceneNetSpecific, Slow, NonParallelizable]
787+
// [Nightly]: wall-clock stress loop (~15s). See Issue1013 rationale.
788+
[Test, LuceneNetSpecific, Slow, Nightly, NonParallelizable]
784789
public void TestConcurrentReadVsSelfDispose_RaceScenario()
785790
{
786791
var dirPath = CreateTempDir("testReadVsSelfDispose");
@@ -850,7 +855,8 @@ public void TestConcurrentReadVsSelfDispose_RaceScenario()
850855
// slicer cascades Dispose to all issued slices; every in-flight
851856
// reader must either finish its current CopyBlockUnaligned or
852857
// observe the closed state cleanly (no AVE).
853-
[Test, LuceneNetSpecific, Slow, NonParallelizable]
858+
// [Nightly]: wall-clock stress loop (~15s). See Issue1013 rationale.
859+
[Test, LuceneNetSpecific, Slow, Nightly, NonParallelizable]
854860
public void TestConcurrentSliceReadVsSlicerDispose_RaceScenario()
855861
{
856862
var dirPath = CreateTempDir("testSliceVsSlicerDispose");
@@ -1211,6 +1217,78 @@ public void TestDisposingOneSliceDoesNotAffectSiblings()
12111217
sliceB.Dispose();
12121218
}
12131219

1220+
// Concurrent-clone read correctness. N workers each take a Clone()
1221+
// of the same root input, seek independently, and read the full
1222+
// file; every worker must observe the exact bytes that were
1223+
// written. Existing concurrent tests only check that reads don't
1224+
// throw — this test pins that readers also don't observe torn or
1225+
// stale bytes under contention over the shared View.basePtr.
1226+
[Test, LuceneNetSpecific, Slow]
1227+
public void TestConcurrentClonesReadIdenticalBytes()
1228+
{
1229+
var dirPath = CreateTempDir("testConcurrentClonesIntegrity");
1230+
using var mmapDir = new MMapDirectory(dirPath);
1231+
const string name = "bytes";
1232+
// 2 MiB — large enough that readers overlap in time, small
1233+
// enough that the test finishes in well under a second per
1234+
// iteration.
1235+
const int fileSize = 2 * 1024 * 1024;
1236+
var expected = new byte[fileSize];
1237+
new Random(0xC0FFEE).NextBytes(expected);
1238+
1239+
using (var io = mmapDir.CreateOutput(name, NewIOContext(Random)))
1240+
{
1241+
io.WriteBytes(expected, 0, expected.Length);
1242+
}
1243+
1244+
using var root = mmapDir.OpenInput(name, NewIOContext(Random));
1245+
1246+
const int numWorkers = 8;
1247+
const int passesPerWorker = 20;
1248+
var errors = new ConcurrentBag<string>();
1249+
var clones = new IndexInput[numWorkers];
1250+
for (int i = 0; i < numWorkers; i++)
1251+
{
1252+
clones[i] = (IndexInput)root.Clone();
1253+
}
1254+
1255+
var start = new ManualResetEventSlim(false);
1256+
var threads = new Thread[numWorkers];
1257+
for (int i = 0; i < numWorkers; i++)
1258+
{
1259+
int idx = i;
1260+
threads[i] = new Thread(() =>
1261+
{
1262+
var buf = new byte[fileSize];
1263+
start.Wait();
1264+
for (int pass = 0; pass < passesPerWorker && errors.IsEmpty; pass++)
1265+
{
1266+
clones[idx].Seek(0);
1267+
clones[idx].ReadBytes(buf, 0, buf.Length);
1268+
// Byte-equal check: any deviation means the read
1269+
// path observed torn or stale data.
1270+
for (int j = 0; j < buf.Length; j++)
1271+
{
1272+
if (buf[j] != expected[j])
1273+
{
1274+
errors.Add(
1275+
$"worker {idx} pass {pass}: byte at {j} expected 0x{expected[j]:X2}, got 0x{buf[j]:X2}");
1276+
return;
1277+
}
1278+
}
1279+
}
1280+
}) { IsBackground = true, Name = "concurrent-integrity-" + i };
1281+
threads[i].Start();
1282+
}
1283+
start.Set();
1284+
foreach (var t in threads) t.Join(TimeSpan.FromSeconds(30));
1285+
1286+
if (!errors.IsEmpty)
1287+
{
1288+
Assert.Fail("Concurrent-clone read corruption detected:\n" + string.Join("\n", errors));
1289+
}
1290+
}
1291+
12141292
// Open a 3.0 CFS index with MMapDirectory and read it end-to-end
12151293
// through DirectoryReader. 3.0 (pre-3.1) CFS files are the only
12161294
// remaining caller of IndexInputSlicer.OpenFullSlice, which in our

src/Lucene.Net/Store/MMapDirectory.cs

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,13 @@ public MMapDirectory(DirectoryInfo path)
103103
/// <param name="maxChunkSize"> maximum chunk size (default is 1 GiBytes for
104104
/// 64 bit runtimes and 256 MiBytes for 32 bit runtimes) used for memory mapping.
105105
/// <para/>
106+
/// <b>LUCENENET note:</b> this parameter is retained for API compatibility
107+
/// but is effectively a no-op in the current implementation. Each file is
108+
/// mapped as a single <see cref="System.IO.MemoryMappedFiles.MemoryMappedViewAccessor"/>
109+
/// regardless of <paramref name="maxChunkSize"/>; see the class remarks for
110+
/// the rationale (issues #1013 and #1151). The value is still validated and
111+
/// retained so that <see cref="MaxChunkSize"/> reflects what the caller passed.
112+
/// <para/>
106113
/// Especially on 32 bit platform, the address space can be very fragmented,
107114
/// so large index files cannot be mapped. Using a lower chunk size makes
108115
/// the directory implementation a little bit slower (as the correct chunk
@@ -162,6 +169,13 @@ public MMapDirectory(string path)
162169
/// <param name="maxChunkSize"> maximum chunk size (default is 1 GiBytes for
163170
/// 64 bit runtimes and 256 MiBytes for 32 bit runtimes) used for memory mapping.
164171
/// <para/>
172+
/// <b>LUCENENET note:</b> this parameter is retained for API compatibility
173+
/// but is effectively a no-op in the current implementation. Each file is
174+
/// mapped as a single <see cref="System.IO.MemoryMappedFiles.MemoryMappedViewAccessor"/>
175+
/// regardless of <paramref name="maxChunkSize"/>; see the class remarks for
176+
/// the rationale (issues #1013 and #1151). The value is still validated and
177+
/// retained so that <see cref="MaxChunkSize"/> reflects what the caller passed.
178+
/// <para/>
165179
/// Especially on 32 bit platform, the address space can be very fragmented,
166180
/// so large index files cannot be mapped. Using a lower chunk size makes
167181
/// the directory implementation a little bit slower (as the correct chunk
@@ -182,7 +196,15 @@ public MMapDirectory(string path, LockFactory lockFactory, int maxChunkSize)
182196
// indeed "release all resources". Therefore unmap hack is not needed in .NET.
183197

184198
/// <summary>
185-
/// Returns the current mmap chunk size. </summary>
199+
/// Returns the current mmap chunk size.
200+
/// <para/>
201+
/// <b>LUCENENET note:</b> this value is retained for API compatibility
202+
/// but does not affect runtime behavior in the current implementation.
203+
/// Each file is mapped as a single
204+
/// <see cref="System.IO.MemoryMappedFiles.MemoryMappedViewAccessor"/>
205+
/// regardless of this setting; see the class remarks (issues #1013 and
206+
/// #1151) for the rationale.
207+
/// </summary>
186208
/// <seealso cref="MMapDirectory(DirectoryInfo, LockFactory, int)"/>
187209
public int MaxChunkSize => 1 << chunkSizePower;
188210

@@ -204,7 +226,10 @@ public override IndexInputSlicer CreateSlicer(string name, IOContext context)
204226
EnsureOpen();
205227
var file = Path.Combine(Directory.FullName, name);
206228
var descriptor = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
207-
return new IndexInputSlicerAnonymousClass(this, context, file, descriptor);
229+
// Cache the length eagerly so OpenFullSlice never has to touch
230+
// the FileStream — which would race with Dispose closing it.
231+
long descriptorLength = descriptor.Length;
232+
return new IndexInputSlicerAnonymousClass(this, context, file, descriptor, descriptorLength);
208233
}
209234

210235
private sealed class IndexInputSlicerAnonymousClass : IndexInputSlicer
@@ -213,6 +238,7 @@ private sealed class IndexInputSlicerAnonymousClass : IndexInputSlicer
213238
private readonly IOContext context;
214239
private readonly string file;
215240
private readonly FileStream descriptor;
241+
private readonly long descriptorLength;
216242
private int disposed = 0; // LUCENENET specific - allow double-dispose
217243
// Track issued slices so that Dispose cascades. Lucene's
218244
// contract is that after slicer.Dispose, reads from any slice
@@ -221,12 +247,13 @@ private readonly SCG.List<MemoryMappedViewAccessorIndexInput> issuedSlices
221247
= new SCG.List<MemoryMappedViewAccessorIndexInput>();
222248
private readonly object issuedSlicesLock = new object();
223249

224-
public IndexInputSlicerAnonymousClass(MMapDirectory outerInstance, IOContext context, string file, FileStream descriptor)
250+
public IndexInputSlicerAnonymousClass(MMapDirectory outerInstance, IOContext context, string file, FileStream descriptor, long descriptorLength)
225251
{
226252
this.outerInstance = outerInstance;
227253
this.context = context;
228254
this.file = file;
229255
this.descriptor = descriptor;
256+
this.descriptorLength = descriptorLength;
230257
}
231258

232259
public override IndexInput OpenSlice(string sliceDescription, long offset, long length)
@@ -257,16 +284,11 @@ public override IndexInput OpenSlice(string sliceDescription, long offset, long
257284
public override IndexInput OpenFullSlice()
258285
{
259286
outerInstance.EnsureOpen();
260-
// Check disposed before touching descriptor. Without this,
261-
// a concurrent Dispose could close the FileStream between
262-
// EnsureOpen and the descriptor.Length read, and the user
263-
// would see an ObjectDisposedException from FileStream
264-
// instead of the conventional AlreadyClosedException.
265-
if (Volatile.Read(ref disposed) != 0)
266-
{
267-
throw AlreadyClosedException.Create(nameof(IndexInputSlicer), "this IndexInputSlicer is closed");
268-
}
269-
return OpenSlice("full-slice", 0, descriptor.Length);
287+
// File length is captured eagerly in the slicer ctor, so we
288+
// never touch the descriptor FileStream here. OpenSlice does
289+
// its own disposed-check under the lock, which will throw
290+
// AlreadyClosed deterministically if we raced with Dispose.
291+
return OpenSlice("full-slice", 0, descriptorLength);
270292
}
271293

272294
protected override void Dispose(bool disposing)

src/Lucene.Net/Store/MemoryMappedViewAccessorIndexInput.cs

Lines changed: 62 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -110,49 +110,76 @@ public View(FileStream fc, long offset, long length)
110110
// another process/thread is appending to this file, the file
111111
// can grow between when we capture fc.Length and when
112112
// CreateFromFile reads the size.
113-
long capacity = Math.Max(offset + length, fc.Length);
114-
const int maxAttempts = 5;
115-
int attempt = 0;
116-
while (true)
113+
MemoryMappedFile localMmf = null;
114+
MemoryMappedViewAccessor localAccessor = null;
115+
byte* ptr = null;
116+
bool pointerAcquired = false;
117+
try
117118
{
118-
try
119+
long capacity = Math.Max(offset + length, fc.Length);
120+
const int maxAttempts = 5;
121+
int attempt = 0;
122+
while (true)
119123
{
120-
this.memoryMappedFile = MemoryMappedFile.CreateFromFile(
121-
fileStream: fc,
122-
mapName: null,
123-
capacity: capacity,
124-
access: MemoryMappedFileAccess.Read,
124+
try
125+
{
126+
localMmf = MemoryMappedFile.CreateFromFile(
127+
fileStream: fc,
128+
mapName: null,
129+
capacity: capacity,
130+
access: MemoryMappedFileAccess.Read,
125131
#if FEATURE_MEMORYMAPPEDFILESECURITY
126-
memoryMappedFileSecurity: null,
132+
memoryMappedFileSecurity: null,
127133
#endif
128-
inheritability: HandleInheritability.Inheritable,
129-
leaveOpen: true); // We dispose the FileStream explicitly.
130-
break;
134+
inheritability: HandleInheritability.Inheritable,
135+
leaveOpen: true); // We dispose the FileStream explicitly.
136+
break;
137+
}
138+
catch (ArgumentOutOfRangeException e) when (e.ParamName == "capacity" && attempt < maxAttempts - 1)
139+
{
140+
Interlocked.Increment(ref MMapDirectory.s_capacityRetryCount);
141+
capacity = Math.Max(capacity, fc.Length);
142+
attempt++;
143+
}
131144
}
132-
catch (ArgumentOutOfRangeException e) when (e.ParamName == "capacity" && attempt < maxAttempts - 1)
145+
int attemptsTaken = attempt + 1;
146+
int prior;
147+
do
133148
{
134-
Interlocked.Increment(ref MMapDirectory.s_capacityRetryCount);
135-
capacity = Math.Max(capacity, fc.Length);
136-
attempt++;
137-
}
138-
}
139-
int attemptsTaken = attempt + 1;
140-
int prior;
141-
do
142-
{
143-
prior = Volatile.Read(ref MMapDirectory.s_maxCapacityAttemptsObserved);
144-
if (attemptsTaken <= prior) break;
145-
} while (Interlocked.CompareExchange(ref MMapDirectory.s_maxCapacityAttemptsObserved, attemptsTaken, prior) != prior);
146-
// LUCENENET specific END
149+
prior = Volatile.Read(ref MMapDirectory.s_maxCapacityAttemptsObserved);
150+
if (attemptsTaken <= prior) break;
151+
} while (Interlocked.CompareExchange(ref MMapDirectory.s_maxCapacityAttemptsObserved, attemptsTaken, prior) != prior);
152+
// LUCENENET specific END
147153

148-
this.accessor = memoryMappedFile.CreateViewAccessor(offset, length, MemoryMappedFileAccess.Read);
154+
localAccessor = localMmf.CreateViewAccessor(offset, length, MemoryMappedFileAccess.Read);
149155

150-
byte* ptr = null;
151-
accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr);
152-
// The accessor may be mapped at an offset inside the OS page,
153-
// in which case PointerOffset is the distance from the
154-
// SafeBuffer's base to the first byte of the requested view.
155-
this.basePtr = ptr + accessor.PointerOffset;
156+
localAccessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr);
157+
pointerAcquired = true;
158+
159+
this.memoryMappedFile = localMmf;
160+
this.accessor = localAccessor;
161+
// The accessor may be mapped at an offset inside the OS page,
162+
// in which case PointerOffset is the distance from the
163+
// SafeBuffer's base to the first byte of the requested view.
164+
this.basePtr = ptr + localAccessor.PointerOffset;
165+
}
166+
catch
167+
{
168+
// The View ctor owns fc (the caller passes ownership). If
169+
// any step above fails we must release everything we
170+
// partially acquired — including fc — before rethrowing,
171+
// or we leak the FileStream/MMF/view handle.
172+
if (pointerAcquired && localAccessor != null)
173+
{
174+
try { localAccessor.SafeMemoryMappedViewHandle.ReleasePointer(); }
175+
catch { /* never propagate from cleanup */ }
176+
}
177+
localAccessor?.Dispose();
178+
localMmf?.Dispose();
179+
try { fc.Dispose(); } catch { /* never propagate from cleanup */ }
180+
this.fc = null;
181+
throw;
182+
}
156183
}
157184

158185
/// <summary>

0 commit comments

Comments
 (0)