diff --git a/src/LocalPrefs.Unity/Assets/Tests/IDBStreamTest.cs b/src/LocalPrefs.Unity/Assets/Tests/IDBStreamTest.cs new file mode 100644 index 0000000..09d4007 --- /dev/null +++ b/src/LocalPrefs.Unity/Assets/Tests/IDBStreamTest.cs @@ -0,0 +1,33 @@ +#if UNITY_WEBGL +#nullable enable + +using System; +using System.Collections; +using NUnit.Framework; +using UnityEngine.TestTools; + +namespace AndanteTribe.IO.Unity.Tests +{ + public class IDBStreamTest + { + [UnityTest] + public IEnumerator MultipleWrites_ArePersistedOnDisposeAsync() + { + yield return new ToCoroutineEnumerator(async () => + { + var path = $"idb-stream-buffered-write-{Guid.NewGuid():N}"; + await using (var stream = new IDBStream(path)) + { + await stream.WriteAsync(new byte[] { 1, 2 }); + await stream.WriteAsync(new byte[] { 3, 4 }); + } + + var actual = await IDBUtils.ReadAllBytesAsync(path); + Assert.That(actual, Is.EqualTo(new byte[] { 1, 2, 3, 4 })); + await IDBUtils.DeleteAsync(path); + }); + } + } +} + +#endif \ No newline at end of file diff --git a/src/LocalPrefs.Unity/Assets/Tests/IDBStreamTest.cs.meta b/src/LocalPrefs.Unity/Assets/Tests/IDBStreamTest.cs.meta new file mode 100644 index 0000000..2230893 --- /dev/null +++ b/src/LocalPrefs.Unity/Assets/Tests/IDBStreamTest.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 0180a9ea46c44c5099c627d5be475110 +timeCreated: 1784369231 diff --git a/src/LocalPrefs.Unity/Assets/Tests/LSPrefsTest.cs b/src/LocalPrefs.Unity/Assets/Tests/LSPrefsTest.cs index 566d301..ef19a05 100644 --- a/src/LocalPrefs.Unity/Assets/Tests/LSPrefsTest.cs +++ b/src/LocalPrefs.Unity/Assets/Tests/LSPrefsTest.cs @@ -1,4 +1,4 @@ -#if UNITY_WEBGL +#if UNITY_WEBGL #nullable enable using System; @@ -200,6 +200,26 @@ public IEnumerator AddAndRemoveMultipleTimes([ValueSource(nameof(s_factories))] await LocalPrefsTest.AddAndRemoveMultipleTimes(factory); }); } + + [Test] + public void LSStream_MultipleWrites_ArePersistedOnFlush() + { + const string path = "ls-stream-buffered-write"; + try + { + using var stream = new LSStream(path); + stream.Write(new byte[] { 1, 2 }, 0, 2); + stream.Write(new byte[] { 3, 4 }, 0, 2); + + Assert.That(LSUtils.ReadAllBytes(path), Is.Empty); + stream.Flush(); + Assert.That(LSUtils.ReadAllBytes(path), Is.EqualTo(new byte[] { 1, 2, 3, 4 })); + } + finally + { + LSUtils.Delete(path); + } + } } } diff --git a/src/LocalPrefs.Unity/Packages/jp.andantetribe.localprefs/Runtime/IDBStream.cs b/src/LocalPrefs.Unity/Packages/jp.andantetribe.localprefs/Runtime/IDBStream.cs index 21bb68b..5b69842 100644 --- a/src/LocalPrefs.Unity/Packages/jp.andantetribe.localprefs/Runtime/IDBStream.cs +++ b/src/LocalPrefs.Unity/Packages/jp.andantetribe.localprefs/Runtime/IDBStream.cs @@ -1,4 +1,4 @@ -#if UNITY_WEBGL +#if UNITY_WEBGL #nullable enable using System; @@ -11,12 +11,18 @@ namespace AndanteTribe.IO.Unity /// /// Represents a stream for IndexedDB operations. /// - /// No multi-threading support because multi-threading is not allowed in the WebGL environment. + /// + /// No multi-threading support because multi-threading is not allowed in the WebGL environment. + /// Writes are buffered until or is called. + /// public class IDBStream : Stream { private readonly string _path; private byte[] _buffer = Array.Empty(); private int _written; + private int _writeVersion; + private bool _isDirty; + private bool _isDisposed; /// public override bool CanRead => true; @@ -46,7 +52,59 @@ public override long Position /// public override void Flush() { - // Flush is typically implemented as an empty method to ensure full compatibility with other Stream types. + ThrowIfDisposed(); + if (_isDirty) + { + throw new NotSupportedException("Synchronous Flush is not supported in WebGL. Use FlushAsync instead."); + } + } + + /// + public override async Task FlushAsync(CancellationToken cancellationToken) + { + ThrowIfDisposed(); + cancellationToken.ThrowIfCancellationRequested(); + if (!_isDirty) + { + return; + } + + var flushedVersion = _writeVersion; + await IDBUtils.WriteAllBytesAsync(_path, new ReadOnlyMemory(_buffer, 0, _written), cancellationToken); + if (_writeVersion == flushedVersion) + { + _isDirty = false; + } + } + + /// + public override async ValueTask DisposeAsync() + { + if (_isDisposed) + { + return; + } + + await FlushAsync(CancellationToken.None); + Dispose(); + GC.SuppressFinalize(this); + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing && !_isDisposed) + { + if (_isDirty) + { + throw new InvalidOperationException("The stream has buffered data. Use DisposeAsync to persist it to IndexedDB."); + } + + _buffer = Array.Empty(); + _isDisposed = true; + } + + base.Dispose(disposing); } /// @@ -91,7 +149,7 @@ public override Task WriteAsync(byte[] buffer, int offset, int count, Cancellati { cancellationToken.ThrowIfCancellationRequested(); WriteBuffer(new ReadOnlySpan(buffer, offset, count)); - return IDBUtils.WriteAllBytesAsync(_path, new ReadOnlyMemory(_buffer, 0, _written), cancellationToken).AsTask(); + return Task.CompletedTask; } /// @@ -99,18 +157,37 @@ public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationTo { cancellationToken.ThrowIfCancellationRequested(); WriteBuffer(buffer.Span); - return IDBUtils.WriteAllBytesAsync(_path, new ReadOnlyMemory(_buffer, 0, _written), cancellationToken); + return default; } private void WriteBuffer(in ReadOnlySpan value) { - if (_buffer.Length < _written + value.Length) + ThrowIfDisposed(); + if (value.IsEmpty) { - Array.Resize(ref _buffer, _written + value.Length); + return; + } + + var requiredLength = checked(_written + value.Length); + if (_buffer.Length < requiredLength) + { + var doubledLength = _buffer.Length > int.MaxValue / 2 ? int.MaxValue : _buffer.Length * 2; + var newLength = _buffer.Length == 0 ? requiredLength : Math.Max(requiredLength, doubledLength); + Array.Resize(ref _buffer, newLength); } value.CopyTo(_buffer.AsSpan()[_written..]); _written += value.Length; + _writeVersion++; + _isDirty = true; + } + + private void ThrowIfDisposed() + { + if (_isDisposed) + { + throw new ObjectDisposedException(nameof(IDBStream)); + } } } } diff --git a/src/LocalPrefs.Unity/Packages/jp.andantetribe.localprefs/Runtime/LSStream.cs b/src/LocalPrefs.Unity/Packages/jp.andantetribe.localprefs/Runtime/LSStream.cs index 745f8ea..80eff4e 100644 --- a/src/LocalPrefs.Unity/Packages/jp.andantetribe.localprefs/Runtime/LSStream.cs +++ b/src/LocalPrefs.Unity/Packages/jp.andantetribe.localprefs/Runtime/LSStream.cs @@ -1,4 +1,4 @@ -#if UNITY_WEBGL +#if UNITY_WEBGL #nullable enable using System; @@ -12,11 +12,14 @@ namespace AndanteTribe.IO.Unity /// /// Represents a stream that reads from and writes to Local Storage in WebGL builds. /// + /// Writes are buffered until or is called. public class LSStream : Stream { private readonly string _path; private NativeArray _buffer; private int _written; + private bool _isDirty; + private bool _isDisposed; /// public override bool CanRead => true; @@ -46,9 +49,20 @@ public override long Position /// protected override void Dispose(bool disposing) { - if (_buffer.IsCreated) + if (disposing && !_isDisposed) { - _buffer.Dispose(); + try + { + Flush(); + } + finally + { + if (_buffer.IsCreated) + { + _buffer.Dispose(); + } + _isDisposed = true; + } } base.Dispose(disposing); } @@ -56,7 +70,14 @@ protected override void Dispose(bool disposing) /// public override void Flush() { - // Flush is typically implemented as an empty method to ensure full compatibility with other Stream types. + ThrowIfDisposed(); + if (!_isDirty) + { + return; + } + + LSUtils.WriteAllBytes(_path, _buffer.AsSpan()[.._written]); + _isDirty = false; } /// @@ -103,7 +124,7 @@ public override int ReadByte() /// public override void Write(byte[] buffer, int offset, int count) => - LSUtils.WriteAllBytes(_path, WriteBuffer(new ReadOnlySpan(buffer, offset, count))); + WriteBuffer(new ReadOnlySpan(buffer, offset, count)); /// public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) @@ -117,22 +138,27 @@ public override Task WriteAsync(byte[] buffer, int offset, int count, Cancellati public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); - LSUtils.WriteAllBytes(_path, WriteBuffer(buffer.Span)); + WriteBuffer(buffer.Span); return default; } - private ReadOnlySpan WriteBuffer(in ReadOnlySpan value) + private void WriteBuffer(in ReadOnlySpan value) { - if (!_buffer.IsCreated && _buffer.Length != 0) + ThrowIfDisposed(); + if (value.IsEmpty) { - throw new ObjectDisposedException(nameof(LSStream)); + return; } - if (_buffer.Length < _written + value.Length) + + var requiredLength = checked(_written + value.Length); + if (_buffer.Length < requiredLength) { - var newBuffer = new NativeArray(_written + value.Length, Allocator.Persistent); - if (_buffer.Length != 0) + var doubledLength = _buffer.Length > int.MaxValue / 2 ? int.MaxValue : _buffer.Length * 2; + var newLength = _buffer.Length == 0 ? requiredLength : Math.Max(requiredLength, doubledLength); + var newBuffer = new NativeArray(newLength, Allocator.Persistent); + if (_written != 0) { - _buffer.CopyTo(newBuffer); + _buffer.AsSpan()[.._written].CopyTo(newBuffer.AsSpan()); _buffer.Dispose(); } _buffer = newBuffer; @@ -140,9 +166,17 @@ private ReadOnlySpan WriteBuffer(in ReadOnlySpan value) value.CopyTo(_buffer.AsSpan()[_written..]); _written += value.Length; - return _buffer.AsSpan()[.._written]; + _isDirty = true; + } + + private void ThrowIfDisposed() + { + if (_isDisposed) + { + throw new ObjectDisposedException(nameof(LSStream)); + } } } } -#endif +#endif \ No newline at end of file