Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement RandomAccess.FlushToDisk() #89100

Merged
merged 9 commits into from
Aug 7, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions src/libraries/System.IO.FileSystem/tests/RandomAccess/Base.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ public abstract class RandomAccess_Base<T> : FileSystemTest

protected virtual bool UsesOffsets => true;

protected virtual bool ThrowsForUnseekableFile => true;

public static IEnumerable<object[]> GetSyncAsyncOptions()
{
yield return new object[] { FileOptions.None };
Expand Down Expand Up @@ -52,10 +54,13 @@ public void ThrowsObjectDisposedExceptionForDisposedHandle()
[SkipOnPlatform(TestPlatforms.Browser, "System.IO.Pipes aren't supported on browser")]
public void ThrowsNotSupportedExceptionForUnseekableFile()
{
using (var server = new AnonymousPipeServerStream(PipeDirection.Out))
using (SafeFileHandle handle = new SafeFileHandle(server.SafePipeHandle.DangerousGetHandle(), ownsHandle: false))
if (ThrowsForUnseekableFile)
{
Assert.Throws<NotSupportedException>(() => MethodUnderTest(handle, Array.Empty<byte>(), 0));
using (var server = new AnonymousPipeServerStream(PipeDirection.Out))
using (SafeFileHandle handle = new SafeFileHandle(server.SafePipeHandle.DangerousGetHandle(), ownsHandle: false))
{
Assert.Throws<NotSupportedException>(() => MethodUnderTest(handle, Array.Empty<byte>(), 0));
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Security.Cryptography;
using Microsoft.Win32.SafeHandles;
using Xunit;

namespace System.IO.Tests
{
public partial class RandomAccess_FlushToDisk : RandomAccess_Base<long>
{
[Fact]
public void FlushFileOpenedForReading()
{
// Save test file path so we can refer to the same file throughout the test.
string testFilePath = GetTestFilePath();

// Write random bytes to file so it exists for reading later below.
const int FileByteCount = 100;
File.WriteAllBytes(testFilePath, RandomNumberGenerator.GetBytes(FileByteCount));

// Open the file for reading.
using (SafeFileHandle handle = File.OpenHandle(testFilePath, FileMode.Open, FileAccess.Read))
{
// On non-Windows platforms (notably Unix), flushing a file opened for reading should succeed.
// It appears that these platforms initially behaved like Windows but then changed their
// behavior (see https://www.austingroupbugs.net/view.php?id=671 for the discussion and
// https://pubs.opengroup.org/onlinepubs/9699919799/functions/aio_fsync.html for the spec
// containing the words "Note that even if the file descriptor is not open for writing...").
RandomAccess.FlushToDisk(handle);
ericmutta marked this conversation as resolved.
Show resolved Hide resolved

// The file length should be unchanged after flushing to disk since we have not written
// anything else to the file.
Assert.Equal(FileByteCount, RandomAccess.GetLength(handle));
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Security.Cryptography;
using Microsoft.Win32.SafeHandles;
using Xunit;

namespace System.IO.Tests
{
public partial class RandomAccess_FlushToDisk : RandomAccess_Base<long>
{
[Fact]
public void FlushFileOpenedForReading()
{
// Save test file path so we can refer to the same file throughout the test.
string testFilePath = GetTestFilePath();

// Write random bytes to file so it exists for reading later below.
const int FileByteCount = 100;
File.WriteAllBytes(testFilePath, RandomNumberGenerator.GetBytes(FileByteCount));

// Open the file for reading.
using (SafeFileHandle handle = File.OpenHandle(testFilePath, FileMode.Open, FileAccess.Read))
{
// On Windows, flushing a file opened for reading should throw an exception. The docs
// for FlushFileBuffers() say the handle must have been created with the GENERIC_WRITE
// access right but that is not the case here since we opened the file for reading.
Assert.Throws<UnauthorizedAccessException>(() => RandomAccess.FlushToDisk(handle));
}
}
}
}
103 changes: 103 additions & 0 deletions src/libraries/System.IO.FileSystem/tests/RandomAccess/FlushToDisk.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.IO.Pipes;
using System.Security.Cryptography;
using Microsoft.Win32.SafeHandles;
using Xunit;

namespace System.IO.Tests
{
public partial class RandomAccess_FlushToDisk : RandomAccess_Base<long>
{
// Setting this to false disables the ThrowsArgumentOutOfRangeExceptionForNegativeFileOffset() test
// which is not applicable to FlushToDisk() since FlushToDisk() does not take a file offset parameter.
ericmutta marked this conversation as resolved.
Show resolved Hide resolved
protected override bool UsesOffsets => false;

// Setting this to false disables the ThrowsNotSupportedExceptionForUnseekableFile() test which is not
// applicable to FlushToDisk() since FlushToDisk() DOES in fact support unseekable files.
protected override bool ThrowsForUnseekableFile => false;

protected override long MethodUnderTest(SafeFileHandle handle, byte[] bytes, long fileOffset)
{
// NOTE: tests for checking how FlushToDisk() deals with invalid arguments (e.g. a null handle)
// are implemented in the base class and work by calling this "MethodUnderTest" which we override
// here to call the FlushToDisk() method we want to test.
RandomAccess.FlushToDisk(handle);
return 0;
}

[Fact]
public void UpdatesFileLastWriteTime()
{
// Save test file path so we can refer to the same file throughout the test.
string testFilePath = GetTestFilePath();

// Generate random bytes to write to file. To ensure that flushing works correctly for a variety of
// sizes, we want to test with buffers that are smaller than a page (e.g. just 1 byte) and buffers
// that are several times larger than a page (e.g. up to 10 pages).
ericmutta marked this conversation as resolved.
Show resolved Hide resolved
int byteCount = Random.Shared.Next(1, Environment.SystemPageSize * 10);
byte[] randomBytes = RandomNumberGenerator.GetBytes(byteCount);

// Create a new file and open it for writing.
using (SafeFileHandle handle = File.OpenHandle(testFilePath, FileMode.CreateNew, FileAccess.Write))
{
// Write random bytes to file.
RandomAccess.Write(handle, randomBytes, fileOffset: 0);

// Get the file time BEFORE flushing to disk.
DateTime fileTimeBeforeFlush = File.GetLastWriteTimeUtc(testFilePath);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

consider setting a last write time here that's a little older. otherwise you'll fail when the file system timestamp granularity isn't high enough. at an extreme, this test will fail on a FAT32 partition.

also, this test will fail if a daylight savings change or leap second happens during the test. obviously, that's unlikely, but several of our tests fail every time there's such an event as there's zillions of tests and they run all the time. to fix that, you can set a date that's a year ago, for example.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting points @danmoseley! In this test we are not explicitly setting the last write time so I can't control it in any way. When the file is flushed, it is the operating system that updates the last write time and the test is just checking that the time after flushing is greater than the time before flushing (or that they are at least equal, as explained in the comment near the Assert at the end).

The most important comment in the test says:

 // Flush the file to disk. As a bare minimum test, this should work without throwing an
 // exception on all supported platforms. 

...i.e. we are mostly interested in the fact that flushing works without throwing an exception on all platforms. So I can remove all the date-related asserts to avoid the issues you raised. Please let me know if I should do this 👍


// Flush the file to disk. As a bare minimum test, this should work without throwing an
// exception on all supported platforms. Since testing that the file was actually flushed
// to disk is difficult without using direct/unbuffered I/O and all the challenges that come
// with it, we can at least test that the file time was updated and we do that later below.
// NOTE: the difficulty of testing this is even called out in the POSIX specification for
// fsync() (see https://pubs.opengroup.org/onlinepubs/009695399/functions/fsync.html).
RandomAccess.FlushToDisk(handle);

// Get the file time AFTER flushing to disk.
DateTime fileTimeAfterFlush = File.GetLastWriteTimeUtc(testFilePath);

// After explicitly flushing to disk, we expect "fileTimeAfterFlush > fileTimeBeforeFlush".
ericmutta marked this conversation as resolved.
Show resolved Hide resolved
// However, the OS is free to flush the file to disk at any moment after the file is written
// to. This "implicit flush" would also update the file time and if it happens BEFORE we set the
// fileTimeBeforeFlush variable above, then both fileTimeBeforeFlush and fileTimeAfterFlush will
// end up with the same value, which is why we use the ">=" operator below instead of using ">".
Assert.True(fileTimeAfterFlush >= fileTimeBeforeFlush);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

another reason to set your own older timestamp before the flush is you eliminate this race. and then you can use Assert.NotEqual here instead. which also has the advantage that if it fails, it will be clear why, whereas when you use Assert.True it just says false expected true.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please see this response, I believe the decision we make there will address this too 👍

}
}

[Fact]
public void CanFlushWithoutWriting()
{
// Create a new file and open it for writing.
using (SafeFileHandle handle = File.OpenHandle(GetTestFilePath(), FileMode.CreateNew, FileAccess.Write))
{
// Flush the file to disk. NOTE: we have created a file but have not written anything to it yet
// so there is nothing to flush to disk. This should succeed without throwing an exception.
RandomAccess.FlushToDisk(handle);

// Furthermore, the file length should be 0 bytes after flushing to disk since we have not written
// anything to the file yet.
Assert.Equal(0, RandomAccess.GetLength(handle));
}
}

[Fact]
[SkipOnPlatform(TestPlatforms.Browser, "System.IO.Pipes aren't supported on browser")]
public void CanFlushUnseekableFile()
{
using (var server = new AnonymousPipeServerStream(PipeDirection.Out))
using (SafeFileHandle handle = new SafeFileHandle(server.SafePipeHandle.DangerousGetHandle(), ownsHandle: false))
{
// Flushing a non-seekable handle (in this case, a pipe handle) should work without throwing an
// exception. On Windows, the FlushFileBuffers() function DOES work with non-seekable handles
// (e.g. pipe handles) and that is what we are testing here. The fsync() function on Unix does
// NOT support non-seekable handles but no exception is thrown on Unix either because we silently
// ignore the errors effectively making the call below a no-op.
RandomAccess.FlushToDisk(handle);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,80 @@ public async Task ReadShouldReturnZeroForEndOfFile(bool asyncOperation, bool asy
Assert.Equal(fileSize, total);
}

[Fact]
public void ReadFromDiskAfterFlushToDisk()
{
// Save test file path so we can refer to the same file throughout the test.
ericmutta marked this conversation as resolved.
Show resolved Hide resolved
string testFilePath = GetTestFilePath();

// Generate random bytes to write to file. NOTE: since we use unbuffered I/O,
// the byte count must be a multiple of the storage device's sector size. We
ericmutta marked this conversation as resolved.
Show resolved Hide resolved
// could P/Invoke the DeviceIoControl() function to get the sector size by
// passing IOCTL_DISK_GET_DRIVE_GEOMETRY as a parameter, but for the sake of
// simplicity we will just use the system page size as a proxy. This works because
// both the system page size and the storage device's sector size are typically
// powers of 2 meaning the system page size (which is typically 4,096 bytes) will
// be a multiple of the sector size (which is typically 512 bytes) hence meeting
// the requirements for unbuffered I/O.
int byteCount = Random.Shared.Next(1, 10) * Environment.SystemPageSize;
ericmutta marked this conversation as resolved.
Show resolved Hide resolved
byte[] randomBytes = RandomNumberGenerator.GetBytes(byteCount);

// Create a new file and open it for writing.
using (SafeFileHandle handle = File.OpenHandle(testFilePath, FileMode.CreateNew, FileAccess.Write))
{
// Write random bytes to file. NOTE: this write does NOT use unbuffered I/O, i.e. the written bytes
// should end up in the file system cache so we can then flush them to disk below.
RandomAccess.Write(handle, randomBytes, fileOffset: 0);

// Flush the file to disk. Later on below we will use unbuffered I/O to read the file directly from disk.
RandomAccess.FlushToDisk(handle);
}

// At this point, FlushToDisk() should have ensured the file's contents are on disk. To confirm this, we will
// now read the file using unbuffered I/O which reads directly from disk, bypassing the file system cache. If
// FlushToDisk() worked correctly, the bytes we read should match the bytes we wrote above. NOTE: unbuffered
// I/O requires the buffer we read into to be aligned to the storage device's sector size which is why we use
// the SectorAlignedMemory<T> type here.
using (SafeFileHandle handle = File.OpenHandle(testFilePath, FileMode.Open, FileAccess.Read, options: NoBuffering))
using (SectorAlignedMemory<byte> buffer = SectorAlignedMemory<byte>.Allocate(randomBytes.Length))
{
// Since the Read() function may return fewer bytes than we requested, we need to keep track of
// how far into the file we are and how many bytes we read each time so we can continue reading
// until we have read the entire file.
int currentBytesRead = 0;
int nextFileReadOffset = 0;

// Keep reading until we reach the end of the file. When we reach the end of the file, the Read()
// function will return a count of 0 bytes which will cause the loop to terminate.
do
{
// Read bytes from disk. NOTE: this read uses unbuffered I/O, i.e. the bytes are read directly from
// disk and into the buffer we provide. This is important for this test because we want to confirm
// that the call to FlushToDisk() above worked correctly and the bytes we wrote to the file are now
// on disk. If we used buffered I/O here, the bytes would be read from the file system cache instead
// of from disk, which would defeat the purpose of this test.
currentBytesRead = RandomAccess.Read(handle, buffer.GetSpan(), fileOffset: nextFileReadOffset);

// Extract the actual and expected bytes as we go along. We are basically reading chunks of bytes from
// the file and comparing them to the corresponding chunks of bytes we wrote to the file earlier. NOTE:
// when we reach the end of the file, the Read() function will return a count of 0 bytes which will cause
// the spans created below to be zero length - this is NOT an error and does not need special handling.
Span<byte> expectedBytes = randomBytes.AsSpan(nextFileReadOffset, currentBytesRead);
Span<byte> actualBytes = buffer.GetSpan().Slice(0, currentBytesRead);

// Confirm the chunk of bytes we read match the corresponding chunk of bytes we wrote earlier.
Assert.True(expectedBytes.SequenceEqual(actualBytes));

// Update the offset into the file so we can read the next chunk of bytes.
nextFileReadOffset += currentBytesRead;
}
while (currentBytesRead != 0);

// At this point, we should have read the entire file.
Assert.Equal(randomBytes.Length, nextFileReadOffset);
}
}

// when using FileOptions.Asynchronous we are testing Scatter&Gather APIs on Windows (FILE_FLAG_OVERLAPPED requirement)
private static FileOptions GetFileOptions(bool asyncHandle) => (asyncHandle ? FileOptions.Asynchronous : FileOptions.None) | NoBuffering;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
<Compile Include="Path\Exists_Directory.cs" />
<Compile Include="Path\Exists_File.cs" />
<Compile Include="RandomAccess\Base.cs" />
<Compile Include="RandomAccess\FlushToDisk.cs" />
<Compile Include="RandomAccess\GetLength.cs" />
<Compile Include="RandomAccess\Read.cs" />
<Compile Include="RandomAccess\ReadAsync.cs" />
Expand All @@ -86,6 +87,7 @@
<Compile Include="$(CommonPath)Interop\Unix\Interop.Libraries.cs" Link="Interop\Unix\Interop.Libraries.cs" />
<Compile Include="FileStream\ctor_options.Unix.cs" />
<Compile Include="Directory\CreateDirectory_UnixFileMode.Unix.cs" />
<Compile Include="RandomAccess\FlushToDisk.Unix.cs" />
</ItemGroup>
<ItemGroup Condition="'$(TargetPlatformIdentifier)' == 'windows'">
<Compile Include="Directory\Delete.Windows.cs" />
Expand All @@ -95,6 +97,7 @@
<Compile Include="FileStream\ctor_options.Windows.cs" />
<Compile Include="FileStream\FileStreamConformanceTests.Windows.cs" />
<Compile Include="Junctions.Windows.cs" />
<Compile Include="RandomAccess\FlushToDisk.Windows.cs" />
<Compile Include="RandomAccess\Mixed.Windows.cs" />
<Compile Include="RandomAccess\NoBuffering.Windows.cs" />
<Compile Include="RandomAccess\SectorAlignedMemory.Windows.cs" />
Expand Down
Loading
Loading