diff --git a/Dalamud.Test/Storage/ReliableFileStorageTests.cs b/Dalamud.Test/Storage/ReliableFileStorageTests.cs index ff56293e0..8a955e430 100644 --- a/Dalamud.Test/Storage/ReliableFileStorageTests.cs +++ b/Dalamud.Test/Storage/ReliableFileStorageTests.cs @@ -31,19 +31,19 @@ public class ReliableFileStorageTests .Select( i => Parallel.ForEachAsync( Enumerable.Range(1, 100), - (j, _) => + async (j, _) => { if (i % 2 == 0) { // ReSharper disable once AccessToDisposedClosure - rfs.Instance.WriteAllText(tempFile, j.ToString()); + await rfs.Instance.WriteAllTextAsync(tempFile, j.ToString()); } else if (i % 3 == 0) { try { // ReSharper disable once AccessToDisposedClosure - rfs.Instance.ReadAllText(tempFile); + await rfs.Instance.ReadAllTextAsync(tempFile); } catch (FileNotFoundException) { @@ -54,8 +54,6 @@ public class ReliableFileStorageTests { File.Delete(tempFile); } - - return ValueTask.CompletedTask; }))); } @@ -112,41 +110,41 @@ public class ReliableFileStorageTests } [Fact] - public void Exists_WhenFileInBackup_ReturnsTrue() + public async Task Exists_WhenFileInBackup_ReturnsTrue() { var tempFile = Path.Combine(CreateTempDir(), TestFileName); using var rfs = CreateRfs(); - rfs.Instance.WriteAllText(tempFile, TestFileContent1); + await rfs.Instance.WriteAllTextAsync(tempFile, TestFileContent1); File.Delete(tempFile); Assert.True(rfs.Instance.Exists(tempFile)); } [Fact] - public void Exists_WhenFileInBackup_WithDifferentContainerId_ReturnsFalse() + public async Task Exists_WhenFileInBackup_WithDifferentContainerId_ReturnsFalse() { var tempFile = Path.Combine(CreateTempDir(), TestFileName); using var rfs = CreateRfs(); - rfs.Instance.WriteAllText(tempFile, TestFileContent1); + await rfs.Instance.WriteAllTextAsync(tempFile, TestFileContent1); File.Delete(tempFile); Assert.False(rfs.Instance.Exists(tempFile, Guid.NewGuid())); } [Fact] - public void WriteAllText_ThrowsIfPathIsEmpty() + public async Task WriteAllText_ThrowsIfPathIsEmpty() { using var rfs = CreateRfs(); - Assert.Throws(() => rfs.Instance.WriteAllText("", TestFileContent1)); + await Assert.ThrowsAsync(async () => await rfs.Instance.WriteAllTextAsync("", TestFileContent1)); } [Fact] - public void WriteAllText_ThrowsIfPathIsNull() + public async Task WriteAllText_ThrowsIfPathIsNull() { using var rfs = CreateRfs(); - Assert.Throws(() => rfs.Instance.WriteAllText(null!, TestFileContent1)); + await Assert.ThrowsAsync(async () => await rfs.Instance.WriteAllTextAsync(null!, TestFileContent1)); } [Fact] @@ -155,26 +153,26 @@ public class ReliableFileStorageTests var tempFile = Path.Combine(CreateTempDir(), TestFileName); using var rfs = CreateRfs(); - rfs.Instance.WriteAllText(tempFile, TestFileContent1); + await rfs.Instance.WriteAllTextAsync(tempFile, TestFileContent1); Assert.True(File.Exists(tempFile)); - Assert.Equal(TestFileContent1, rfs.Instance.ReadAllText(tempFile, forceBackup: true)); + Assert.Equal(TestFileContent1, await rfs.Instance.ReadAllTextAsync(tempFile, forceBackup: true)); Assert.Equal(TestFileContent1, await File.ReadAllTextAsync(tempFile)); } [Fact] - public void WriteAllText_SeparatesContainers() + public async Task WriteAllText_SeparatesContainers() { var tempFile = Path.Combine(CreateTempDir(), TestFileName); var containerId = Guid.NewGuid(); using var rfs = CreateRfs(); - rfs.Instance.WriteAllText(tempFile, TestFileContent1); - rfs.Instance.WriteAllText(tempFile, TestFileContent2, containerId); + await rfs.Instance.WriteAllTextAsync(tempFile, TestFileContent1); + await rfs.Instance.WriteAllTextAsync(tempFile, TestFileContent2, containerId); File.Delete(tempFile); - Assert.Equal(TestFileContent1, rfs.Instance.ReadAllText(tempFile, forceBackup: true)); - Assert.Equal(TestFileContent2, rfs.Instance.ReadAllText(tempFile, forceBackup: true, containerId)); + Assert.Equal(TestFileContent1, await rfs.Instance.ReadAllTextAsync(tempFile, forceBackup: true)); + Assert.Equal(TestFileContent2, await rfs.Instance.ReadAllTextAsync(tempFile, forceBackup: true, containerId)); } [Fact] @@ -183,7 +181,7 @@ public class ReliableFileStorageTests var tempFile = Path.Combine(CreateTempDir(), TestFileName); using var rfs = CreateFailedRfs(); - rfs.Instance.WriteAllText(tempFile, TestFileContent1); + await rfs.Instance.WriteAllTextAsync(tempFile, TestFileContent1); Assert.True(File.Exists(tempFile)); Assert.Equal(TestFileContent1, await File.ReadAllTextAsync(tempFile)); @@ -195,38 +193,38 @@ public class ReliableFileStorageTests var tempFile = Path.Combine(CreateTempDir(), TestFileName); using var rfs = CreateRfs(); - rfs.Instance.WriteAllText(tempFile, TestFileContent1); - rfs.Instance.WriteAllText(tempFile, TestFileContent2); + await rfs.Instance.WriteAllTextAsync(tempFile, TestFileContent1); + await rfs.Instance.WriteAllTextAsync(tempFile, TestFileContent2); Assert.True(File.Exists(tempFile)); - Assert.Equal(TestFileContent2, rfs.Instance.ReadAllText(tempFile, forceBackup: true)); + Assert.Equal(TestFileContent2, await rfs.Instance.ReadAllTextAsync(tempFile, forceBackup: true)); Assert.Equal(TestFileContent2, await File.ReadAllTextAsync(tempFile)); } [Fact] - public void WriteAllText_SupportsNullContent() + public async Task WriteAllText_SupportsNullContent() { var tempFile = Path.Combine(CreateTempDir(), TestFileName); using var rfs = CreateRfs(); - rfs.Instance.WriteAllText(tempFile, null); + await rfs.Instance.WriteAllTextAsync(tempFile, null); Assert.True(File.Exists(tempFile)); - Assert.Equal("", rfs.Instance.ReadAllText(tempFile)); + Assert.Equal("", await rfs.Instance.ReadAllTextAsync(tempFile)); } [Fact] - public void ReadAllText_ThrowsIfPathIsEmpty() + public async Task ReadAllText_ThrowsIfPathIsEmpty() { using var rfs = CreateRfs(); - Assert.Throws(() => rfs.Instance.ReadAllText("")); + await Assert.ThrowsAsync(async () => await rfs.Instance.ReadAllTextAsync("")); } [Fact] - public void ReadAllText_ThrowsIfPathIsNull() + public async Task ReadAllText_ThrowsIfPathIsNull() { using var rfs = CreateRfs(); - Assert.Throws(() => rfs.Instance.ReadAllText(null!)); + await Assert.ThrowsAsync(async () => await rfs.Instance.ReadAllTextAsync(null!)); } [Fact] @@ -236,40 +234,40 @@ public class ReliableFileStorageTests await File.WriteAllTextAsync(tempFile, TestFileContent1); using var rfs = CreateRfs(); - Assert.Equal(TestFileContent1, rfs.Instance.ReadAllText(tempFile)); + Assert.Equal(TestFileContent1, await rfs.Instance.ReadAllTextAsync(tempFile)); } [Fact] - public void ReadAllText_WhenFileMissingWithBackup_ReturnsContent() + public async Task ReadAllText_WhenFileMissingWithBackup_ReturnsContent() { var tempFile = Path.Combine(CreateTempDir(), TestFileName); using var rfs = CreateRfs(); - rfs.Instance.WriteAllText(tempFile, TestFileContent1); + await rfs.Instance.WriteAllTextAsync(tempFile, TestFileContent1); File.Delete(tempFile); - Assert.Equal(TestFileContent1, rfs.Instance.ReadAllText(tempFile)); + Assert.Equal(TestFileContent1, await rfs.Instance.ReadAllTextAsync(tempFile)); } [Fact] - public void ReadAllText_WhenFileMissingWithBackup_ThrowsWithDifferentContainerId() + public async Task ReadAllText_WhenFileMissingWithBackup_ThrowsWithDifferentContainerId() { var tempFile = Path.Combine(CreateTempDir(), TestFileName); var containerId = Guid.NewGuid(); using var rfs = CreateRfs(); - rfs.Instance.WriteAllText(tempFile, TestFileContent1); + await rfs.Instance.WriteAllTextAsync(tempFile, TestFileContent1); File.Delete(tempFile); - Assert.Throws(() => rfs.Instance.ReadAllText(tempFile, containerId: containerId)); + await Assert.ThrowsAsync(async () => await rfs.Instance.ReadAllTextAsync(tempFile, containerId: containerId)); } [Fact] - public void ReadAllText_WhenFileMissing_ThrowsIfDbFailed() + public async Task ReadAllText_WhenFileMissing_ThrowsIfDbFailed() { var tempFile = Path.Combine(CreateTempDir(), TestFileName); using var rfs = CreateFailedRfs(); - Assert.Throws(() => rfs.Instance.ReadAllText(tempFile)); + await Assert.ThrowsAsync(async () => await rfs.Instance.ReadAllTextAsync(tempFile)); } [Fact] @@ -278,7 +276,7 @@ public class ReliableFileStorageTests var tempFile = Path.Combine(CreateTempDir(), TestFileName); await File.WriteAllTextAsync(tempFile, TestFileContent1); using var rfs = CreateRfs(); - rfs.Instance.ReadAllText(tempFile, text => Assert.Equal(TestFileContent1, text)); + await rfs.Instance.ReadAllTextAsync(tempFile, text => Assert.Equal(TestFileContent1, text)); } [Fact] @@ -290,7 +288,7 @@ public class ReliableFileStorageTests var readerCalledOnce = false; using var rfs = CreateRfs(); - Assert.Throws(() => rfs.Instance.ReadAllText(tempFile, Reader)); + await Assert.ThrowsAsync(async () => await rfs.Instance.ReadAllTextAsync(tempFile, Reader)); return; @@ -303,7 +301,7 @@ public class ReliableFileStorageTests } [Fact] - public void ReadAllText_WithReader_WhenReaderThrows_ReadsContentFromBackup() + public async Task ReadAllText_WithReader_WhenReaderThrows_ReadsContentFromBackup() { var tempFile = Path.Combine(CreateTempDir(), TestFileName); @@ -311,10 +309,10 @@ public class ReliableFileStorageTests var assertionCalled = false; using var rfs = CreateRfs(); - rfs.Instance.WriteAllText(tempFile, TestFileContent1); + await rfs.Instance.WriteAllTextAsync(tempFile, TestFileContent1); File.Delete(tempFile); - rfs.Instance.ReadAllText(tempFile, Reader); + await rfs.Instance.ReadAllTextAsync(tempFile, Reader); Assert.True(assertionCalled); return; @@ -335,17 +333,17 @@ public class ReliableFileStorageTests var tempFile = Path.Combine(CreateTempDir(), TestFileName); await File.WriteAllTextAsync(tempFile, TestFileContent1); using var rfs = CreateRfs(); - Assert.Throws(() => rfs.Instance.ReadAllText(tempFile, _ => throw new FileNotFoundException())); + await Assert.ThrowsAsync(async () => await rfs.Instance.ReadAllTextAsync(tempFile, _ => throw new FileNotFoundException())); } [Theory] [InlineData(true)] [InlineData(false)] - public void ReadAllText_WhenFileDoesNotExist_Throws(bool forceBackup) + public async Task ReadAllText_WhenFileDoesNotExist_Throws(bool forceBackup) { var tempFile = Path.Combine(CreateTempDir(), TestFileName); using var rfs = CreateRfs(); - Assert.Throws(() => rfs.Instance.ReadAllText(tempFile, forceBackup)); + await Assert.ThrowsAsync(async () => await rfs.Instance.ReadAllTextAsync(tempFile, forceBackup)); } private static DisposableReliableFileStorage CreateRfs() diff --git a/Dalamud/Configuration/Internal/DalamudConfiguration.cs b/Dalamud/Configuration/Internal/DalamudConfiguration.cs index 241a08d90..da0d7c2c6 100644 --- a/Dalamud/Configuration/Internal/DalamudConfiguration.cs +++ b/Dalamud/Configuration/Internal/DalamudConfiguration.cs @@ -503,13 +503,13 @@ internal sealed class DalamudConfiguration : IInternalDisposableService /// Path to read from. /// File storage. /// The deserialized configuration file. - public static DalamudConfiguration Load(string path, ReliableFileStorage fs) + public static async Task Load(string path, ReliableFileStorage fs) { DalamudConfiguration deserialized = null; try { - fs.ReadAllText(path, text => + await fs.ReadAllTextAsync(path, text => { deserialized = JsonConvert.DeserializeObject(text, SerializerSettings); @@ -580,8 +580,6 @@ internal sealed class DalamudConfiguration : IInternalDisposableService { this.Save(); this.isSaveQueued = false; - - Log.Verbose("Config saved"); } } @@ -630,16 +628,20 @@ internal sealed class DalamudConfiguration : IInternalDisposableService // Wait for previous write to finish this.writeTask?.Wait(); - this.writeTask = Task.Run(() => + this.writeTask = Task.Run(async () => { - Service.Get().WriteAllText( - this.configPath, - JsonConvert.SerializeObject(this, SerializerSettings)); + await Service.Get().WriteAllTextAsync( + this.configPath, + JsonConvert.SerializeObject(this, SerializerSettings)); + Log.Verbose("DalamudConfiguration saved"); }).ContinueWith(t => { if (t.IsFaulted) { - Log.Error(t.Exception, "Failed to save DalamudConfiguration to {Path}", this.configPath); + Log.Error( + t.Exception, + "Failed to save DalamudConfiguration to {Path}", + this.configPath); } }); diff --git a/Dalamud/Configuration/PluginConfigurations.cs b/Dalamud/Configuration/PluginConfigurations.cs index 8e32fa992..fa2969d31 100644 --- a/Dalamud/Configuration/PluginConfigurations.cs +++ b/Dalamud/Configuration/PluginConfigurations.cs @@ -2,6 +2,8 @@ using System.IO; using System.Reflection; using Dalamud.Storage; +using Dalamud.Utility; + using Newtonsoft.Json; namespace Dalamud.Configuration; @@ -9,6 +11,7 @@ namespace Dalamud.Configuration; /// /// Configuration to store settings for a dalamud plugin. /// +[Api13ToDo("Make this a service. We need to be able to dispose it reliably to write configs asynchronously. Maybe also let people write files with vfs.")] public sealed class PluginConfigurations { private readonly DirectoryInfo configDirectory; @@ -36,7 +39,7 @@ public sealed class PluginConfigurations public void Save(IPluginConfiguration config, string pluginName, Guid workingPluginId) { Service.Get() - .WriteAllText(this.GetConfigFile(pluginName).FullName, SerializeConfig(config), workingPluginId); + .WriteAllTextAsync(this.GetConfigFile(pluginName).FullName, SerializeConfig(config), workingPluginId).GetAwaiter().GetResult(); } /// @@ -52,12 +55,12 @@ public sealed class PluginConfigurations IPluginConfiguration? config = null; try { - Service.Get().ReadAllText(path.FullName, text => + Service.Get().ReadAllTextAsync(path.FullName, text => { config = DeserializeConfig(text); if (config == null) throw new Exception("Read config was null."); - }, workingPluginId); + }, workingPluginId).GetAwaiter().GetResult(); } catch (FileNotFoundException) { diff --git a/Dalamud/EntryPoint.cs b/Dalamud/EntryPoint.cs index 9fc09a56b..15077f3d8 100644 --- a/Dalamud/EntryPoint.cs +++ b/Dalamud/EntryPoint.cs @@ -144,7 +144,8 @@ public sealed class EntryPoint // Load configuration first to get some early persistent state, like log level var fs = new ReliableFileStorage(Path.GetDirectoryName(info.ConfigurationPath)!); - var configuration = DalamudConfiguration.Load(info.ConfigurationPath!, fs); + var configuration = DalamudConfiguration.Load(info.ConfigurationPath!, fs) + .GetAwaiter().GetResult(); // Set the appropriate logging level from the configuration if (!configuration.LogSynchronously) diff --git a/Dalamud/Game/UnlockState/UnlockState.cs b/Dalamud/Game/UnlockState/UnlockState.cs index a4b9381cc..cd896ffb6 100644 --- a/Dalamud/Game/UnlockState/UnlockState.cs +++ b/Dalamud/Game/UnlockState/UnlockState.cs @@ -22,7 +22,7 @@ using PublicContentSheet = Lumina.Excel.Sheets.PublicContent; namespace Dalamud.Game.UnlockState; -#pragma warning disable UnlockState +#pragma warning disable Dalamud001 /// /// This class provides unlock state of various content in the game. diff --git a/Dalamud/Interface/Internal/Windows/Data/Widgets/VfsWidget.cs b/Dalamud/Interface/Internal/Windows/Data/Widgets/VfsWidget.cs index f1f2476c9..f044b2989 100644 --- a/Dalamud/Interface/Internal/Windows/Data/Widgets/VfsWidget.cs +++ b/Dalamud/Interface/Internal/Windows/Data/Widgets/VfsWidget.cs @@ -52,7 +52,7 @@ internal class VfsWidget : IDataWindowWidget for (var i = 0; i < this.reps; i++) { stopwatch.Restart(); - service.WriteAllBytes(path, data); + service.WriteAllBytesAsync(path, data).GetAwaiter().GetResult(); stopwatch.Stop(); acc += stopwatch.ElapsedMilliseconds; Log.Information("Turn {Turn} took {Ms}ms", i, stopwatch.ElapsedMilliseconds); @@ -70,7 +70,7 @@ internal class VfsWidget : IDataWindowWidget for (var i = 0; i < this.reps; i++) { stopwatch.Restart(); - service.ReadAllBytes(path); + service.ReadAllBytesAsync(path).GetAwaiter().GetResult(); stopwatch.Stop(); acc += stopwatch.ElapsedMilliseconds; Log.Information("Turn {Turn} took {Ms}ms", i, stopwatch.ElapsedMilliseconds); diff --git a/Dalamud/Plugin/SelfTest/Internal/SelfTestRegistryPluginScoped.cs b/Dalamud/Plugin/SelfTest/Internal/SelfTestRegistryPluginScoped.cs index 18c518879..e3835ddbc 100644 --- a/Dalamud/Plugin/SelfTest/Internal/SelfTestRegistryPluginScoped.cs +++ b/Dalamud/Plugin/SelfTest/Internal/SelfTestRegistryPluginScoped.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using Dalamud.IoC; using Dalamud.IoC.Internal; using Dalamud.Plugin.Internal.Types; +using Dalamud.Plugin.Services; namespace Dalamud.Plugin.SelfTest.Internal; @@ -12,7 +13,7 @@ namespace Dalamud.Plugin.SelfTest.Internal; [PluginInterface] [ServiceManager.ScopedService] [ResolveVia] -internal class SelfTestRegistryPluginScoped : ISelfTestRegistry, IInternalDisposableService +internal class SelfTestRegistryPluginScoped : ISelfTestRegistry, IInternalDisposableService, IDalamudService { [ServiceManager.ServiceDependency] private readonly SelfTestRegistry selfTestRegistry = Service.Get(); diff --git a/Dalamud/Plugin/Services/IReliableFileStorage.cs b/Dalamud/Plugin/Services/IReliableFileStorage.cs new file mode 100644 index 000000000..757493a20 --- /dev/null +++ b/Dalamud/Plugin/Services/IReliableFileStorage.cs @@ -0,0 +1,168 @@ +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Text; +using System.Threading.Tasks; + +using Dalamud.Configuration; +using Dalamud.Storage; + +namespace Dalamud.Plugin.Services; + +/// +/// Service to interact with the file system, as a replacement for standard C# file I/O. +/// Writes and reads using this service are, to the best of our ability, atomic and reliable. +/// +/// All data is synced to disk immediately and written to a database, additionally to files on disk. This means +/// that in case of file corruption, data can likely be recovered from the database. +/// +/// However, this also means that operations using this service duplicate data on disk, so we don't recommend +/// performing large file operations. The service will not permit files larger than +/// (64MB) to be written. +/// +/// Saved configuration data using the class uses this functionality implicitly. +/// +[Experimental("Dalamud001")] +public interface IReliableFileStorage : IDalamudService +{ + /// + /// Gets the maximum file size, in bytes, that can be written using this service. + /// + /// + /// The service enforces this limit when writing files and fails with an appropriate exception + /// (for example or a custom exception) when a caller attempts to write + /// more than this number of bytes. + /// + long MaxFileSizeBytes { get; } + + /// + /// Check whether a file exists either on the local filesystem or in the transparent backup database. + /// + /// The file system path to check. Must not be null or empty. + /// + /// True if the file exists on disk or a backup copy exists in the storage's internal journal/backup database; + /// otherwise false. + /// + /// Thrown when is null or empty. + bool Exists(string path); + + /// + /// Write the given text into a file using UTF-8 encoding. The write is performed atomically and is persisted to + /// both the filesystem and the internal backup database used by this service. + /// + /// The file path to write to. Must not be null or empty. + /// The string contents to write. May be null, in which case an empty file is written. + /// A that completes when the write has finished and been flushed to disk and the backup. + /// Thrown when is null or empty. + Task WriteAllTextAsync(string path, string? contents); + + /// + /// Write the given text into a file using the provided . The write is performed + /// atomically (to the extent possible) and is persisted to both the filesystem and the internal backup database + /// used by this service. + /// + /// The file path to write to. Must not be null or empty. + /// The string contents to write. May be null, in which case an empty file is written. + /// The text encoding to use when serializing the string to bytes. Must not be null. + /// A that completes when the write has finished and been flushed to disk and the backup. + /// Thrown when is null or empty. + /// Thrown when is null. + Task WriteAllTextAsync(string path, string? contents, Encoding encoding); + + /// + /// Write the given bytes to a file. The write is persisted to both the filesystem and the service's internal + /// backup database. Avoid writing extremely large byte arrays because this service duplicates data on disk. + /// + /// The file path to write to. Must not be null or empty. + /// The raw bytes to write. Must not be null. + /// A that completes when the write has finished and been flushed to disk and the backup. + /// Thrown when is null or empty. + /// Thrown when is null. + Task WriteAllBytesAsync(string path, byte[] bytes); + + /// + /// Read all text from a file using UTF-8 encoding. If the file is unreadable or missing on disk, the service + /// attempts to return a backed-up copy from its internal journal/backup database. + /// + /// The file path to read. Must not be null or empty. + /// + /// When true the service prefers the internal backup database and returns backed-up contents if available. When + /// false the service tries the filesystem first and falls back to the backup only on error or when the file is missing. + /// + /// The textual contents of the file, decoded using UTF-8. + /// Thrown when is null or empty. + /// Thrown when the file does not exist on disk and no backup copy is available. + Task ReadAllTextAsync(string path, bool forceBackup = false); + + /// + /// Read all text from a file using the specified . If the file is unreadable or + /// missing on disk, the service attempts to return a backed-up copy from its internal journal/backup database. + /// + /// The file path to read. Must not be null or empty. + /// The encoding to use when decoding the stored bytes into text. Must not be null. + /// + /// When true the service prefers the internal backup database and returns backed-up contents if available. When + /// false the service tries the filesystem first and falls back to the backup only on error or when the file is missing. + /// + /// The textual contents of the file decoded using the provided . + /// Thrown when is null or empty. + /// Thrown when is null. + /// Thrown when the file does not exist on disk and no backup copy is available. + Task ReadAllTextAsync(string path, Encoding encoding, bool forceBackup = false); + + /// + /// Read all text from a file and invoke the provided callback with the string + /// contents. If the reader throws or the initial read fails, the service attempts a backup read and invokes the + /// reader again with the backup contents. If both reads fail the service surfaces an exception to the caller. + /// + /// The file path to read. Must not be null or empty. + /// + /// A callback invoked with the file's textual contents. Must not be null. + /// If the callback throws an exception the service treats that as a signal to retry the read using the + /// internal backup database and will invoke the callback again with the backup contents when available. + /// For example, the callback can throw when JSON deserialization fails to request the backup copy instead of + /// silently accepting corrupt data. + /// + /// A that completes when the read (and any attempted fallback) and callback invocation have finished. + /// Thrown when is null or empty. + /// Thrown when is null. + /// Thrown when the file does not exist on disk and no backup copy is available. + /// Thrown when both the filesystem read and the backup read fail for other reasons. + Task ReadAllTextAsync(string path, Action reader); + + /// + /// Read all text from a file using the specified and invoke the provided + /// callback with the decoded string contents. If the reader throws or the initial + /// read fails, the service attempts a backup read and invokes the reader again with the backup contents. If + /// both reads fail the service surfaces an exception to the caller. + /// + /// The file path to read. Must not be null or empty. + /// The encoding to use when decoding the stored bytes into text. Must not be null. + /// + /// A callback invoked with the file's textual contents. Must not be null. + /// If the callback throws an exception the service treats that as a signal to retry the read using the + /// internal backup database and will invoke the callback again with the backup contents when available. + /// For example, the callback can throw when JSON deserialization fails to request the backup copy instead of + /// silently accepting corrupt data. + /// + /// A that completes when the read (and any attempted fallback) and callback invocation have finished. + /// Thrown when is null or empty. + /// Thrown when or is null. + /// Thrown when the file does not exist on disk and no backup copy is available. + /// Thrown when both the filesystem read and the backup read fail for other reasons. + Task ReadAllTextAsync(string path, Encoding encoding, Action reader); + + /// + /// Read all bytes from a file. If the file is unreadable or missing on disk, the service may try to return a + /// backed-up copy from its internal journal/backup database. + /// + /// The file path to read. Must not be null or empty. + /// + /// When true the service prefers the internal backup database and returns the backed-up contents + /// if available. When false the service tries the filesystem first and falls back to the backup only + /// on error or when the file is missing. + /// + /// The raw bytes stored in the file. + /// Thrown when is null or empty. + /// Thrown when the file does not exist on disk and no backup copy is available. + Task ReadAllBytesAsync(string path, bool forceBackup = false); +} diff --git a/Dalamud/Plugin/Services/IUnlockState.cs b/Dalamud/Plugin/Services/IUnlockState.cs index a0d733f55..0409843c4 100644 --- a/Dalamud/Plugin/Services/IUnlockState.cs +++ b/Dalamud/Plugin/Services/IUnlockState.cs @@ -10,7 +10,7 @@ namespace Dalamud.Plugin.Services; /// /// Interface for determining unlock state of various content in the game. /// -[Experimental("UnlockState")] +[Experimental("Dalamud001")] public interface IUnlockState : IDalamudService { /// diff --git a/Dalamud/Storage/ReliableFileStorage.cs b/Dalamud/Storage/ReliableFileStorage.cs index 9791b9e45..d9f8526c3 100644 --- a/Dalamud/Storage/ReliableFileStorage.cs +++ b/Dalamud/Storage/ReliableFileStorage.cs @@ -1,5 +1,6 @@ using System.IO; using System.Text; +using System.Threading.Tasks; using Dalamud.Logging.Internal; using Dalamud.Utility; @@ -92,8 +93,9 @@ internal class ReliableFileStorage : IInternalDisposableService /// Path to write to. /// The contents of the file. /// Container to write to. - public void WriteAllText(string path, string? contents, Guid containerId = default) - => this.WriteAllText(path, contents, Encoding.UTF8, containerId); + /// A representing the asynchronous operation. + public async Task WriteAllTextAsync(string path, string? contents, Guid containerId = default) + => await this.WriteAllTextAsync(path, contents, Encoding.UTF8, containerId); /// /// Write all text to a file. @@ -102,10 +104,11 @@ internal class ReliableFileStorage : IInternalDisposableService /// The contents of the file. /// The encoding to write with. /// Container to write to. - public void WriteAllText(string path, string? contents, Encoding encoding, Guid containerId = default) + /// A representing the asynchronous operation. + public async Task WriteAllTextAsync(string path, string? contents, Encoding encoding, Guid containerId = default) { var bytes = encoding.GetBytes(contents ?? string.Empty); - this.WriteAllBytes(path, bytes, containerId); + await this.WriteAllBytesAsync(path, bytes, containerId); } /// @@ -114,7 +117,8 @@ internal class ReliableFileStorage : IInternalDisposableService /// Path to write to. /// The contents of the file. /// Container to write to. - public void WriteAllBytes(string path, byte[] bytes, Guid containerId = default) + /// A representing the asynchronous operation. + public Task WriteAllBytesAsync(string path, byte[] bytes, Guid containerId = default) { ArgumentException.ThrowIfNullOrEmpty(path); @@ -123,7 +127,7 @@ internal class ReliableFileStorage : IInternalDisposableService if (this.db == null) { FilesystemUtil.WriteAllBytesSafe(path, bytes); - return; + return Task.CompletedTask; } this.db.RunInTransaction(() => @@ -149,6 +153,8 @@ internal class ReliableFileStorage : IInternalDisposableService FilesystemUtil.WriteAllBytesSafe(path, bytes); }); } + + return Task.CompletedTask; } /// @@ -161,8 +167,8 @@ internal class ReliableFileStorage : IInternalDisposableService /// The container to read from. /// All text stored in this file. /// Thrown if the file does not exist on the filesystem or in the backup. - public string ReadAllText(string path, bool forceBackup = false, Guid containerId = default) - => this.ReadAllText(path, Encoding.UTF8, forceBackup, containerId); + public Task ReadAllTextAsync(string path, bool forceBackup = false, Guid containerId = default) + => this.ReadAllTextAsync(path, Encoding.UTF8, forceBackup, containerId); /// /// Read all text from a file. @@ -175,9 +181,9 @@ internal class ReliableFileStorage : IInternalDisposableService /// The container to read from. /// All text stored in this file. /// Thrown if the file does not exist on the filesystem or in the backup. - public string ReadAllText(string path, Encoding encoding, bool forceBackup = false, Guid containerId = default) + public async Task ReadAllTextAsync(string path, Encoding encoding, bool forceBackup = false, Guid containerId = default) { - var bytes = this.ReadAllBytes(path, forceBackup, containerId); + var bytes = await this.ReadAllBytesAsync(path, forceBackup, containerId); return encoding.GetString(bytes); } @@ -191,8 +197,9 @@ internal class ReliableFileStorage : IInternalDisposableService /// The container to read from. /// Thrown if the file does not exist on the filesystem or in the backup. /// Thrown here if the file and the backup fail their read. - public void ReadAllText(string path, Action reader, Guid containerId = default) - => this.ReadAllText(path, Encoding.UTF8, reader, containerId); + /// A representing the asynchronous operation. + public async Task ReadAllTextAsync(string path, Action reader, Guid containerId = default) + => await this.ReadAllTextAsync(path, Encoding.UTF8, reader, containerId); /// /// Read all text from a file, and automatically try again with the backup if the file does not exist or @@ -205,7 +212,8 @@ internal class ReliableFileStorage : IInternalDisposableService /// The container to read from. /// Thrown if the file does not exist on the filesystem or in the backup. /// Thrown here if the file and the backup fail their read. - public void ReadAllText(string path, Encoding encoding, Action reader, Guid containerId = default) + /// A representing the asynchronous operation. + public async Task ReadAllTextAsync(string path, Encoding encoding, Action reader, Guid containerId = default) { ArgumentException.ThrowIfNullOrEmpty(path); @@ -216,7 +224,7 @@ internal class ReliableFileStorage : IInternalDisposableService // 1.) Try without using the backup try { - var text = this.ReadAllText(path, encoding, false, containerId); + var text = await this.ReadAllTextAsync(path, encoding, false, containerId); reader(text); return; } @@ -233,7 +241,7 @@ internal class ReliableFileStorage : IInternalDisposableService // 2.) Try using the backup try { - var text = this.ReadAllText(path, encoding, true, containerId); + var text = await this.ReadAllTextAsync(path, encoding, true, containerId); reader(text); } catch (Exception ex) @@ -253,7 +261,7 @@ internal class ReliableFileStorage : IInternalDisposableService /// The container to read from. /// All bytes stored in this file. /// Thrown if the file does not exist on the filesystem or in the backup. - public byte[] ReadAllBytes(string path, bool forceBackup = false, Guid containerId = default) + public async Task ReadAllBytesAsync(string path, bool forceBackup = false, Guid containerId = default) { ArgumentException.ThrowIfNullOrEmpty(path); @@ -265,15 +273,12 @@ internal class ReliableFileStorage : IInternalDisposableService var normalizedPath = NormalizePath(path); var file = this.db.Table().FirstOrDefault(f => f.Path == normalizedPath && f.ContainerId == containerId); - if (file == null) - throw new FileNotFoundException(); - - return file.Data; + return file == null ? throw new FileNotFoundException() : file.Data; } // If the file doesn't exist, immediately check the backup db if (!File.Exists(path)) - return this.ReadAllBytes(path, true, containerId); + return await this.ReadAllBytesAsync(path, true, containerId); try { @@ -282,7 +287,7 @@ internal class ReliableFileStorage : IInternalDisposableService catch (Exception e) { Log.Error(e, "Failed to read file from disk, falling back to database"); - return this.ReadAllBytes(path, true, containerId); + return await this.ReadAllBytesAsync(path, true, containerId); } } diff --git a/Dalamud/Storage/ReliableFileStoragePluginScoped.cs b/Dalamud/Storage/ReliableFileStoragePluginScoped.cs new file mode 100644 index 000000000..59d6bccc4 --- /dev/null +++ b/Dalamud/Storage/ReliableFileStoragePluginScoped.cs @@ -0,0 +1,199 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +using Dalamud.IoC; +using Dalamud.IoC.Internal; +using Dalamud.Plugin.Internal.Types; +using Dalamud.Plugin.Services; + +namespace Dalamud.Storage; + +#pragma warning disable Dalamud001 + +/// +/// Plugin-scoped VFS wrapper. +/// +[PluginInterface] +[ServiceManager.ScopedService] +#pragma warning disable SA1015 +[ResolveVia] +#pragma warning restore SA1015 +public class ReliableFileStoragePluginScoped : IReliableFileStorage, IInternalDisposableService +{ + private readonly Lock pendingLock = new(); + private readonly HashSet pendingWrites = []; + + private readonly LocalPlugin plugin; + + [ServiceManager.ServiceDependency] + private readonly ReliableFileStorage storage = Service.Get(); + + // When true, the scope is disposing and new write requests are rejected. + private volatile bool isDisposing = false; + + /// + /// Initializes a new instance of the class. + /// + /// The owner plugin. + [ServiceManager.ServiceConstructor] + internal ReliableFileStoragePluginScoped(LocalPlugin plugin) + { + this.plugin = plugin; + } + + /// + public long MaxFileSizeBytes => 64 * 1024 * 1024; + + /// + public bool Exists(string path) + { + if (this.isDisposing) + throw new ObjectDisposedException(nameof(ReliableFileStoragePluginScoped)); + + return this.storage.Exists(path, this.plugin.EffectiveWorkingPluginId); + } + + /// + public Task WriteAllTextAsync(string path, string? contents) + { + // Route through WriteAllBytesAsync so all write tracking and size checks are centralized. + ArgumentException.ThrowIfNullOrEmpty(path); + + var bytes = Encoding.UTF8.GetBytes(contents ?? string.Empty); + return this.WriteAllBytesAsync(path, bytes); + } + + /// + public Task WriteAllTextAsync(string path, string? contents, Encoding encoding) + { + // Route through WriteAllBytesAsync so all write tracking and size checks are centralized. + ArgumentException.ThrowIfNullOrEmpty(path); + ArgumentNullException.ThrowIfNull(encoding); + + var bytes = encoding.GetBytes(contents ?? string.Empty); + return this.WriteAllBytesAsync(path, bytes); + } + + /// + public Task WriteAllBytesAsync(string path, byte[] bytes) + { + ArgumentException.ThrowIfNullOrEmpty(path); + ArgumentNullException.ThrowIfNull(bytes); + + if (bytes.LongLength > this.MaxFileSizeBytes) + throw new ArgumentException($"The provided data exceeds the maximum allowed size of {this.MaxFileSizeBytes} bytes.", nameof(bytes)); + + // Start the underlying write task + var task = Task.Run(() => this.storage.WriteAllBytesAsync(path, bytes, this.plugin.EffectiveWorkingPluginId)); + + // Track the task so we can wait for it on dispose + lock (this.pendingLock) + { + if (this.isDisposing) + throw new ObjectDisposedException(nameof(ReliableFileStoragePluginScoped)); + + this.pendingWrites.Add(task); + } + + // Remove when done, if the task is already done this runs synchronously here and removes immediately + _ = task.ContinueWith(t => + { + lock (this.pendingLock) + { + this.pendingWrites.Remove(t); + } + }, TaskContinuationOptions.ExecuteSynchronously); + + return task; + } + + /// + public Task ReadAllTextAsync(string path, bool forceBackup = false) + { + if (this.isDisposing) + throw new ObjectDisposedException(nameof(ReliableFileStoragePluginScoped)); + + ArgumentException.ThrowIfNullOrEmpty(path); + + return this.storage.ReadAllTextAsync(path, forceBackup, this.plugin.EffectiveWorkingPluginId); + } + + /// + public Task ReadAllTextAsync(string path, Encoding encoding, bool forceBackup = false) + { + if (this.isDisposing) + throw new ObjectDisposedException(nameof(ReliableFileStoragePluginScoped)); + + ArgumentException.ThrowIfNullOrEmpty(path); + ArgumentNullException.ThrowIfNull(encoding); + + return this.storage.ReadAllTextAsync(path, encoding, forceBackup, this.plugin.EffectiveWorkingPluginId); + } + + /// + public Task ReadAllTextAsync(string path, Action reader) + { + if (this.isDisposing) + throw new ObjectDisposedException(nameof(ReliableFileStoragePluginScoped)); + + ArgumentException.ThrowIfNullOrEmpty(path); + ArgumentNullException.ThrowIfNull(reader); + + return this.storage.ReadAllTextAsync(path, reader, this.plugin.EffectiveWorkingPluginId); + } + + /// + public Task ReadAllTextAsync(string path, Encoding encoding, Action reader) + { + if (this.isDisposing) + throw new ObjectDisposedException(nameof(ReliableFileStoragePluginScoped)); + + ArgumentException.ThrowIfNullOrEmpty(path); + ArgumentNullException.ThrowIfNull(encoding); + ArgumentNullException.ThrowIfNull(reader); + + return this.storage.ReadAllTextAsync(path, encoding, reader, this.plugin.EffectiveWorkingPluginId); + } + + /// + public Task ReadAllBytesAsync(string path, bool forceBackup = false) + { + if (this.isDisposing) + throw new ObjectDisposedException(nameof(ReliableFileStoragePluginScoped)); + + ArgumentException.ThrowIfNullOrEmpty(path); + + return this.storage.ReadAllBytesAsync(path, forceBackup, this.plugin.EffectiveWorkingPluginId); + } + + /// + public void DisposeService() + { + Task[] tasksSnapshot; + lock (this.pendingLock) + { + // Mark disposing to reject new writes. + this.isDisposing = true; + + if (this.pendingWrites.Count == 0) + return; + + tasksSnapshot = this.pendingWrites.ToArray(); + } + + try + { + // Wait for all pending writes to complete. If some complete while we're waiting they will be in tasksSnapshot + // and are observed here; newly started writes are rejected due to isDisposing. + Task.WaitAll(tasksSnapshot); + } + catch (AggregateException) + { + // Swallow exceptions here: the underlying write failures will have been surfaced earlier to callers. + // We don't want dispose to throw and crash unload sequences. + } + } +}