Skip to content

Commit 1e20009

Browse files
paulirwinclaude
andcommitted
Address PR #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 4b7a965 commit 1e20009

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
@@ -193,31 +193,24 @@ public override IndexInput OpenInput(string name, IOContext context)
193193
// Matches upstream Java (openInput creates a new FileChannel +
194194
// fc.map()) and ensures Length reflects the file's current size.
195195
SharedMapping mapping = SharedMapping.Create(file, chunkSizePower);
196-
try
197-
{
198-
return new MMapIndexInput($"MMapIndexInput(path=\"{file}\")", ownsMapping: true, mapping, 0, mapping.Length, chunkSizePower);
199-
}
200-
catch
201-
{
202-
mapping.Dispose();
203-
throw;
204-
}
196+
// Ownership of the mapping transfers to the returned root
197+
// MMapIndexInput (ownsMapping: true); the caller of OpenInput is
198+
// responsible for disposing that input, which disposes the mapping.
199+
// The constructor only sets fields and cannot throw here, so no
200+
// catch/dispose guard is needed.
201+
return new MMapIndexInput($"MMapIndexInput(path=\"{file}\")", ownsMapping: true, mapping, 0, mapping.Length, chunkSizePower);
205202
}
206203

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

223216
private sealed class IndexInputSlicerAnonymousClass : IndexInputSlicer
@@ -245,6 +238,15 @@ public IndexInputSlicerAnonymousClass(MMapDirectory outerInstance, string file,
245238
this.mapping = mapping;
246239
}
247240

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

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

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

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

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

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

9851026
internal Chunk[] Chunks { get; }
@@ -1018,27 +1059,22 @@ private static Chunk[] MapChunks(MemoryMappedFile? mmf, long offset, long length
10181059

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

0 commit comments

Comments
 (0)