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