diff --git a/src/System.IO.FileSystem/src/System/IO/Win32FileStream.cs b/src/System.IO.FileSystem/src/System/IO/Win32FileStream.cs index ef2bff7e6cfd..9554a5e4e552 100644 --- a/src/System.IO.FileSystem/src/System/IO/Win32FileStream.cs +++ b/src/System.IO.FileSystem/src/System/IO/Win32FileStream.cs @@ -61,6 +61,7 @@ internal sealed partial class Win32FileStream : FileStreamBase private long _appendStart;// When appending, prevent overwriting file. private Task _lastSynchronouslyCompletedTask = null; + private Task _activeBufferOperation = null; [System.Security.SecuritySafeCritical] public Win32FileStream(String path, FileMode mode, FileAccess access, FileShare share, int bufferSize, FileOptions options, FileStream parent) : base(parent) @@ -331,6 +332,10 @@ private unsafe void VerifyHandleIsSync() } } + private bool HasActiveBufferOperation + { + get { return _activeBufferOperation != null && !_activeBufferOperation.IsCompleted; } + } public override bool CanRead { @@ -520,6 +525,26 @@ private void FlushRead() _readLen = 0; } + // Returns a task that flushes the internal write buffer + private Task FlushWriteAsync(CancellationToken cancellationToken) + { + Debug.Assert(_isAsync); + Debug.Assert(_readPos == 0 && _readLen == 0, "FileStream: Read buffer must be empty in FlushWriteAsync!"); + + // If the buffer is already flushed, don't spin up the OS write + if (_writePos == 0) return Task.CompletedTask; + + Task flushTask = WriteInternalCoreAsync(_buffer, 0, _writePos, cancellationToken); + _writePos = 0; + + // Update the active buffer operation + _activeBufferOperation = HasActiveBufferOperation ? + Task.WhenAll(_activeBufferOperation, flushTask) : + flushTask; + + return flushTask; + } + // Writes are buffered. Anytime the buffer fills up // (_writePos + delta > _bufferSize) or the buffer switches to reading // and there is left over data (_writePos > 0), this function must be called. @@ -529,7 +554,7 @@ private void FlushWrite(bool calledFromFinalizer) if (_isAsync) { - Task writeTask = WriteInternalCoreAsync(_buffer, 0, _writePos, CancellationToken.None); + Task writeTask = FlushWriteAsync(CancellationToken.None); // With our Whidbey async IO & overlapped support for AD unloads, // we don't strictly need to block here to release resources // since that support takes care of the pinning & freeing the @@ -1277,13 +1302,14 @@ private Task WriteInternalAsync(byte[] array, int offset, int numBytes, Cancella // pipes. Debug.Assert(_readPos == 0 && _readLen == 0, "Win32FileStream must not have buffered data here! Pipes should be unidirectional."); + // If there's data in the write buffer, flush it before starting a new write if (_writePos > 0) - FlushWrite(false); + FlushWriteAsync(cancellationToken); return WriteInternalCoreAsync(array, offset, numBytes, cancellationToken); } - // Handle buffering. + // Ensure the buffer is clear for writing if (_writePos == 0) { if (_readPos < _readLen) FlushRead(); @@ -1291,19 +1317,68 @@ private Task WriteInternalAsync(byte[] array, int offset, int numBytes, Cancella _readLen = 0; } - int n = _bufferSize - _writePos; - if (numBytes <= n) + // There are a few different cases to handle when performing the write operation + // surrounding the internal buffer. Buffer flush operations can be issued asynchronously + // so the state of any async operation that touches the internal buffer must be tracked + // to ensure there are no data races at play. + // + // 1. No active async buffer op and numBytes fits in existing buffer: + // Simply perform a memory copy operation and return synchronously. + // + // 2. Active async buffer op and numBytes <= _bufferSize + // Since there's an active buffer operation, a new buffer needs to be allocated + // to write to in order to ensure the existing buffer isn't modified mid-write. + // The incoming write can then be copied into the new buffer. + // + // 3. No active async buffer and numBytes too large for buffer + // If there's buffered data, issue a flush operation. Then, regardless, issue the + // incoming write since it can't be buffered. + // + // 4. Active async buffer op and numBytes too large for buffer + // If there's buffered data, attach it to the existing async operation using Task.WhenAll + // and then directly issue the incoming write. + // + // Note: since _writePos is reset synchronously when calling FlushWriteAsync, it can be + // assumed that if _writePos > 0 and there's an active async flush operation, the buffer + // was reallocated since that operation was initiated. + + // Case 1 - no active buffer op & fits in remaining size + int remainingBuffer = _bufferSize - _writePos; + if (!HasActiveBufferOperation && numBytes < remainingBuffer) { - if (_writePos == 0) _buffer = new byte[_bufferSize]; + if (_buffer == null) _buffer = new byte[_bufferSize]; + Buffer.BlockCopy(array, offset, _buffer, _writePos, numBytes); _writePos += numBytes; - return Task.CompletedTask; } + // Case 2 - active buffer op & fits in new buffer + if (HasActiveBufferOperation && numBytes < _bufferSize) + { + _buffer = new byte[_bufferSize]; + + Buffer.BlockCopy(array, offset, _buffer, 0, numBytes); + _writePos = numBytes; + return Task.CompletedTask; + } + + // Case 3 - Incoming write can't be buffered and there's existing data in the buffer if (_writePos > 0) - FlushWrite(false); + { + Task flushTask = FlushWriteAsync(cancellationToken); + // If the task has already completed and was unsuccessful, avoid issuing the write. + if (flushTask.IsCanceled || flushTask.IsFaulted) return flushTask; + + // Return both the flush and write operations. It's not necessary that they complete in a + // specific order since the internal position is updated synchronously when crafting the + // tasks, but consumers will want to know if either fails so the health of the stream is known. + return Task.WhenAll(flushTask, WriteInternalCoreAsync(array, offset, numBytes, cancellationToken)); + } + + // Case 4 - Incoming write can't be buffered and there's no existing buffered data, so just + // issue the OS write directly. return WriteInternalCoreAsync(array, offset, numBytes, cancellationToken); } diff --git a/src/System.IO.FileSystem/tests/FileStream/WriteAsync.cs b/src/System.IO.FileSystem/tests/FileStream/WriteAsync.cs index 7fcf8b6b8a87..70b6c58b4e19 100644 --- a/src/System.IO.FileSystem/tests/FileStream/WriteAsync.cs +++ b/src/System.IO.FileSystem/tests/FileStream/WriteAsync.cs @@ -270,6 +270,30 @@ public async Task WriteAsyncCancelledFile() } } + [Fact] + public async void WriteAsyncInternalBufferOverflow() + { + // Overflow into next buffer + using (FileStream fs = new FileStream(GetTestFilePath(), FileMode.Create, FileAccess.Write, FileShare.None, 2)) + { + // Fill existing buffer + await fs.WriteAsync(TestBuffer, 0, 2); + Assert.True(fs.Length == 2); + + // Overflow into next buffer + await fs.WriteAsync(TestBuffer, 0, 1); + Assert.True(fs.Length == 3); + + // Overflow bufferSize * 2 + await fs.WriteAsync(TestBuffer, 0, 5); + Assert.True(fs.Length == 8); + + // Overflow bufferSize * 2 with empty buffer + await fs.WriteAsync(TestBuffer, 0, 5); + Assert.True(fs.Length == 13); + } + } + [Fact, OuterLoop] public async Task WriteAsyncMiniStress() {