Skip to content

Commit 3e9b20a

Browse files
paulirwinclaude
authored andcommitted
Address PR apache#1267 review: FileStream ownership + exception-handling cleanup
SharedMapping now owns and deterministically disposes the backing FileStream (leaveOpen: true) instead of relying on the finalizer; the MemoryMappedFile only borrows the handle and never disposes the stream object. Adds internal test seams (MMapIndexInput.Mapping, SharedMapping.IsFileStreamDisposed) and tests asserting the stream is disposed on input/slicer dispose. Fixes the two DisposeWhileHandlingException misuses (CreateAttempt and MapChunks): dispose via the swallowing overload then rethrow with a bare throw, so the original stack trace is preserved and there is no double-throw. Removes the dead catch/dispose guards in OpenInput and CreateSlicer (the constructors only set fields and cannot throw there) and documents the mapping-ownership and slice-lifetime contracts. Corrects the test comment that implied AlreadyClosedException is a distinct type from ObjectDisposedException. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 093c7fe commit 3e9b20a

2 files changed

Lines changed: 164 additions & 60 deletions

File tree

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

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1444,9 +1444,12 @@ public void TestOpenFullSlice_On3xCfsFile_MatchesOpenInput()
14441444
"OpenFullSlice bytes must match OpenInput bytes for the same CFS file");
14451445
}
14461446

1447-
// OpenFullSlice on a slicer that has been disposed must throw
1448-
// AlreadyClosedException, not ObjectDisposedException leaking from
1449-
// the underlying FileStream. Part of the review-item-6 fix.
1447+
// OpenFullSlice on a slicer that has been disposed must throw the
1448+
// already-closed exception. In Lucene.NET, AlreadyClosedException.Create()
1449+
// is a factory that returns an ObjectDisposedException (there is no
1450+
// distinct AlreadyClosedException type), so this test cannot and does not
1451+
// distinguish the two by type; it asserts via IsAlreadyClosedException(),
1452+
// which matches the ObjectDisposedException that Create() produces.
14501453
[Test, LuceneNetSpecific]
14511454
public void TestOpenFullSlice_AfterDispose_ThrowsAlreadyClosed()
14521455
{
@@ -1492,6 +1495,71 @@ public void TestDisposeIndexInput()
14921495
File.Delete(fileName);
14931496
}
14941497

1498+
// LUCENENET specific: PR #1267 review item. MemoryMappedFile.CreateFromFile
1499+
// borrows the file handle from the FileStream we pass in but never disposes
1500+
// the FileStream object itself. SharedMapping therefore owns that FileStream
1501+
// and must dispose it deterministically on Dispose; otherwise the stream (a
1502+
// finalizable object holding the file handle) is left to the finalizer. We
1503+
// assert the invariant directly through internal members rather than by
1504+
// probing the OS, because the borrowed handle is released either way (the MMF
1505+
// closes it) and so the leak is not observable as a deletion or exclusive-open
1506+
// failure. IsFileStreamDisposed reports whether the owned FileStream was disposed.
1507+
// Note TestDisposeIndexInput above uses a zero-length file, which takes the
1508+
// early-return path that never calls CreateFromFile and owns no FileStream.
1509+
[Test, LuceneNetSpecific]
1510+
public void TestDisposeDisposesBackingFileStream_NonEmptyFile()
1511+
{
1512+
const string name = "bytes";
1513+
var dir = CreateTempDir("testDisposeDisposesBackingFileStream");
1514+
1515+
using MMapDirectory mmapDir = new MMapDirectory(dir);
1516+
using (var output = mmapDir.CreateOutput(name, NewIOContext(Random)))
1517+
{
1518+
output.WriteInt64(0x0123456789ABCDEFL);
1519+
}
1520+
1521+
var input = (MMapDirectory.MMapIndexInput)mmapDir.OpenInput(name, NewIOContext(Random));
1522+
var mapping = input.Mapping;
1523+
Assert.IsFalse(mapping.IsFileStreamDisposed,
1524+
"backing FileStream must still be open while the input is open");
1525+
1526+
input.Dispose();
1527+
1528+
Assert.IsTrue(mapping.IsFileStreamDisposed,
1529+
"disposing the root input must deterministically dispose the mapping's backing FileStream");
1530+
}
1531+
1532+
// LUCENENET specific: PR #1267 review item. The slicer (CreateSlicer) owns its
1533+
// own SharedMapping; disposing the slicer must dispose that mapping's backing
1534+
// FileStream just as disposing a root input does. This is the OpenFullSlice /
1535+
// 3.x CFS path the reviewer called out.
1536+
[Test, LuceneNetSpecific]
1537+
public void TestDisposeSlicerDisposesBackingFileStream_NonEmptyFile()
1538+
{
1539+
const string name = "bytes";
1540+
var dir = CreateTempDir("testDisposeSlicerDisposesBackingFileStream");
1541+
1542+
using MMapDirectory mmapDir = new MMapDirectory(dir);
1543+
using (var output = mmapDir.CreateOutput(name, NewIOContext(Random)))
1544+
{
1545+
output.WriteInt64(0x0123456789ABCDEFL);
1546+
}
1547+
1548+
var slicer = mmapDir.CreateSlicer(name, NewIOContext(Random));
1549+
#pragma warning disable 612, 618
1550+
var full = (MMapDirectory.MMapIndexInput)slicer.OpenFullSlice();
1551+
#pragma warning restore 612, 618
1552+
var mapping = full.Mapping;
1553+
Assert.IsFalse(mapping.IsFileStreamDisposed,
1554+
"backing FileStream must still be open while the slicer is open");
1555+
1556+
full.Dispose();
1557+
slicer.Dispose();
1558+
1559+
Assert.IsTrue(mapping.IsFileStreamDisposed,
1560+
"disposing the slicer must deterministically dispose the mapping's backing FileStream");
1561+
}
1562+
14951563
// LUCENENET specific: tests written to investigate the concern raised
14961564
// in PR #1267 (review comment r3137038502) that the per-file shared
14971565
// mapping cache, keyed only by file name with a fixed Length captured

src/Lucene.Net/Store/MMapDirectory.cs

Lines changed: 93 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -194,31 +194,24 @@ public override IndexInput OpenInput(string name, IOContext context)
194194
// Matches upstream Java (openInput creates a new FileChannel +
195195
// fc.map()) and ensures Length reflects the file's current size.
196196
SharedMapping mapping = SharedMapping.Create(file, chunkSizePower);
197-
try
198-
{
199-
return new MMapIndexInput($"MMapIndexInput(path=\"{file}\")", ownsMapping: true, mapping, 0, mapping.Length, chunkSizePower);
200-
}
201-
catch
202-
{
203-
mapping.Dispose();
204-
throw;
205-
}
197+
// Ownership of the mapping transfers to the returned root
198+
// MMapIndexInput (ownsMapping: true); the caller of OpenInput is
199+
// responsible for disposing that input, which disposes the mapping.
200+
// The constructor only sets fields and cannot throw here, so no
201+
// catch/dispose guard is needed.
202+
return new MMapIndexInput($"MMapIndexInput(path=\"{file}\")", ownsMapping: true, mapping, 0, mapping.Length, chunkSizePower);
206203
}
207204

208205
public override IndexInputSlicer CreateSlicer(string name, IOContext context)
209206
{
210207
EnsureOpen();
211208
var file = Path.Combine(Directory.FullName, name);
212209
SharedMapping mapping = SharedMapping.Create(file, chunkSizePower);
213-
try
214-
{
215-
return new IndexInputSlicerAnonymousClass(this, file, mapping);
216-
}
217-
catch
218-
{
219-
mapping.Dispose();
220-
throw;
221-
}
210+
// Ownership of the mapping transfers to the returned slicer; the
211+
// caller of CreateSlicer is responsible for disposing that slicer,
212+
// which disposes the mapping. The constructor only sets fields and
213+
// cannot throw here, so no catch/dispose guard is needed.
214+
return new IndexInputSlicerAnonymousClass(this, file, mapping);
222215
}
223216

224217
private sealed class IndexInputSlicerAnonymousClass : IndexInputSlicer
@@ -246,6 +239,15 @@ public IndexInputSlicerAnonymousClass(MMapDirectory outerInstance, string file,
246239
this.mapping = mapping;
247240
}
248241

242+
// Returns a slice the CALLER must dispose. The slice does not own the
243+
// mapping (ownsMapping: false); it is also tracked in issuedSlices so
244+
// that disposing the slicer cascades to any slices the caller left
245+
// open. Note a slice can outlive a Dispose of outerInstance (the
246+
// MMapDirectory): we deliberately do not thread outerInstance into the
247+
// slice to re-check on every read, because reads against a disposed
248+
// mapping already fail fast with AlreadyClosedException via the
249+
// mapping's closed flag and per-chunk rent. EnsureOpen here only guards
250+
// the act of opening a new slice.
249251
public override IndexInput OpenSlice(string sliceDescription, long offset, long length)
250252
{
251253
outerInstance.EnsureOpen();
@@ -279,8 +281,13 @@ public override IndexInput OpenSlice(string sliceDescription, long offset, long
279281
public override IndexInput OpenFullSlice()
280282
{
281283
outerInstance.EnsureOpen();
282-
// The shared mapping's Length was captured at creation time,
283-
// so we don't need to touch any FileStream here.
284+
// A full slice is just a slice over the whole mapping. It shares
285+
// the slicer's single SharedMapping (same MemoryMappedFile and
286+
// FileStream) rather than opening a second mapping, and it is
287+
// tracked in issuedSlices like any other slice, so disposing the
288+
// slicer disposes it and the one backing FileStream. The mapping's
289+
// Length was captured at creation time, so we touch no FileStream
290+
// here.
284291
return OpenSlice("full-slice", 0, mapping.Length);
285292
}
286293

@@ -398,6 +405,11 @@ internal sealed unsafe class MMapIndexInput : IndexInput
398405
// on Dispose. Slices and clones do not own it.
399406
private readonly SharedMapping mapping;
400407

408+
// LUCENENET specific (PR #1267): for testing only. Exposes the shared
409+
// mapping so a test can assert that disposing a root input
410+
// deterministically disposes the mapping's backing FileStream.
411+
internal SharedMapping Mapping => mapping;
412+
401413
// The window into the shared mapping that this IndexInput sees.
402414
// For OpenInput this is [0, mapping.Length); for OpenSlice it is
403415
// the requested slice range. All offsets in the cached chunk
@@ -830,11 +842,34 @@ internal sealed unsafe class SharedMapping : IDisposable
830842
/// Note that this can be null in the edge case of a zero-length mapping.
831843
/// </summary>
832844
private readonly MemoryMappedFile? memoryMappedFile;
845+
846+
/// <summary>
847+
/// The <see cref="FileStream"/> backing <see cref="memoryMappedFile"/>.
848+
/// We pass this stream to
849+
/// <see cref="MemoryMappedFile.CreateFromFile(FileStream, string?, long, MemoryMappedFileAccess, HandleInheritability, bool)"/>
850+
/// with <c>leaveOpen: true</c>, so the mapping borrows the file handle
851+
/// but never disposes the <see cref="FileStream"/> object. This mapping
852+
/// owns it and disposes it in <see cref="Dispose"/> so the stream (a
853+
/// finalizable object holding the file handle) is released
854+
/// deterministically rather than left to the finalizer. Null for the
855+
/// zero-length edge case (no mapping is created).
856+
/// </summary>
857+
private readonly FileStream? fileStream;
833858
private int disposed;
834859

835-
private SharedMapping(MemoryMappedFile? mmf, Chunk[] chunks, long length)
860+
// LUCENENET specific (PR #1267): for testing only. True once Dispose has
861+
// run and the owned FileStream (if any) has been disposed. Lets a test
862+
// assert that the mapping releases its FileStream deterministically
863+
// instead of leaking the object to finalization. Always true for the
864+
// zero-length edge case, which owns no FileStream.
865+
internal bool IsFileStreamDisposed =>
866+
Volatile.Read(ref disposed) != 0 &&
867+
(fileStream is null || !fileStream.CanRead);
868+
869+
private SharedMapping(MemoryMappedFile? mmf, FileStream? fileStream, Chunk[] chunks, long length)
836870
{
837871
this.memoryMappedFile = mmf;
872+
this.fileStream = fileStream;
838873
this.Chunks = chunks;
839874
this.Length = length;
840875
}
@@ -910,7 +945,6 @@ private static SharedMapping CreateAttempt(string file, int chunkSizePower)
910945
bufferSize: 1, FileOptions.RandomAccess);
911946
MemoryMappedFile? mmf = null;
912947
Chunk[]? chunks = null;
913-
Exception? priorException = null;
914948
try
915949
{
916950
long length = fs.Length;
@@ -925,7 +959,7 @@ private static SharedMapping CreateAttempt(string file, int chunkSizePower)
925959
// (it would be misleading — we successfully built a
926960
// zero-length mapping).
927961
IOUtils.DisposeWhileHandlingException(fs);
928-
return new SharedMapping(mmf: null, chunks: Array.Empty<Chunk>(), length: 0);
962+
return new SharedMapping(mmf: null, fileStream: null, chunks: Array.Empty<Chunk>(), length: 0);
929963
}
930964

931965
// capacity: 0 -> the framework sizes the mapping
@@ -935,9 +969,14 @@ private static SharedMapping CreateAttempt(string file, int chunkSizePower)
935969
// the length is re-read across the defaulting and
936970
// validation steps, which is why Create wraps this
937971
// call in a retry loop.
938-
// leaveOpen: false -> the MMF takes ownership of
939-
// the FileStream and disposes it on its own
940-
// Dispose, so we don't need to track it ourselves.
972+
// leaveOpen: true -> the MMF borrows fs's file handle
973+
// but does not close it; SharedMapping owns the
974+
// FileStream and disposes it (which closes the handle)
975+
// in Dispose. Note that even with leaveOpen: false the
976+
// MMF would close only the handle, never the FileStream
977+
// object itself, so we must track fs either way; using
978+
// leaveOpen: true keeps a single, unambiguous owner of
979+
// the handle and avoids a redundant handle close.
941980
mmf = MemoryMappedFile.CreateFromFile(
942981
fileStream: fs,
943982
mapName: null,
@@ -947,30 +986,28 @@ private static SharedMapping CreateAttempt(string file, int chunkSizePower)
947986
memoryMappedFileSecurity: null,
948987
#endif
949988
inheritability: HandleInheritability.None,
950-
leaveOpen: false);
989+
leaveOpen: true);
951990
chunks = MapChunks(mmf, 0, length, chunkSizePower);
952-
return new SharedMapping(mmf, chunks, length);
991+
return new SharedMapping(mmf, fs, chunks, length);
953992
}
954993
catch (Exception e) when (e.IsThrowable())
955994
{
956-
priorException = e;
995+
// Cleanup must not mask e. DisposeChunks swallows internally,
996+
// so chunk teardown is safe. We dispose mmf/fs through the
997+
// swallowing overload of DisposeWhileHandlingException (the one
998+
// with no Exception parameter), which suppresses any Dispose
999+
// failure, and then rethrow e with a bare `throw;`. A bare
1000+
// rethrow preserves e's original stack trace, and using the
1001+
// swallowing overload (rather than the priorException overload,
1002+
// which would ALSO throw) avoids a confusing double-throw.
1003+
// With leaveOpen: true we always own fs (the MMF never disposes
1004+
// it), so dispose both the mmf (if it was created) and fs. mmf
1005+
// first so the mapping is torn down before the backing handle
1006+
// is closed.
1007+
DisposeChunks(chunks);
1008+
IOUtils.DisposeWhileHandlingException(mmf, fs);
9571009
throw;
9581010
}
959-
finally
960-
{
961-
if (priorException != null)
962-
{
963-
// Cleanup must not mask priorException. DisposeChunks
964-
// swallows internally, so chunk teardown is safe.
965-
// For the mmf/fs the priorException overload attaches
966-
// any Dispose failure as a suppressed exception and
967-
// rethrows the original.
968-
// mmf owns fs once CreateFromFile returned (leaveOpen:
969-
// false); if mmf is null, fs ownership is still ours.
970-
DisposeChunks(chunks);
971-
IOUtils.DisposeWhileHandlingException(priorException, (IDisposable?)mmf ?? fs);
972-
}
973-
}
9741011
}
9751012

9761013
/// <summary>
@@ -980,7 +1017,11 @@ public void Dispose()
9801017
{
9811018
if (Interlocked.CompareExchange(ref disposed, 1, 0) != 0) return;
9821019
DisposeChunks(Chunks);
983-
IOUtils.DisposeWhileHandlingException(memoryMappedFile);
1020+
// Tear down the mapping before closing the backing handle, then
1021+
// dispose the FileStream we own (the MMF was created with
1022+
// leaveOpen: true and never disposes it). fileStream is null for
1023+
// the zero-length edge case, which the overload tolerates.
1024+
IOUtils.DisposeWhileHandlingException(memoryMappedFile, fileStream);
9841025
}
9851026

9861027
internal Chunk[] Chunks { get; }
@@ -1019,27 +1060,22 @@ private static Chunk[] MapChunks(MemoryMappedFile? mmf, long offset, long length
10191060

10201061
MemoryMappedViewAccessor accessor = mmf.CreateViewAccessor(chunkOffset, thisChunkLen, MemoryMappedFileAccess.Read);
10211062
byte* ptr = null;
1022-
Exception? acquireException = null;
10231063
try
10241064
{
10251065
accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr);
10261066
}
10271067
catch (Exception e) when (e.IsThrowable())
10281068
{
10291069
// Don't let accessor.Dispose() mask the original
1030-
// AcquirePointer failure — route through the
1031-
// priorException overload so any Dispose throw
1032-
// becomes a suppressed exception on the original.
1033-
acquireException = e;
1070+
// AcquirePointer failure: dispose the accessor through the
1071+
// swallowing overload (suppressing any Dispose failure),
1072+
// then rethrow e with a bare `throw;` that preserves its
1073+
// original stack trace. The throw skips the Chunk
1074+
// construction below and propagates to the outer catch,
1075+
// which disposes the already-built chunks.
1076+
IOUtils.DisposeWhileHandlingException(accessor);
10341077
throw;
10351078
}
1036-
finally
1037-
{
1038-
if (acquireException != null)
1039-
{
1040-
IOUtils.DisposeWhileHandlingException(acquireException, accessor);
1041-
}
1042-
}
10431079
// The accessor may be mapped at an offset inside the OS page,
10441080
// in which case PointerOffset is the distance from the
10451081
// SafeBuffer's base to the first byte of the requested view.

0 commit comments

Comments
 (0)