launcher: add atomic staging-to-final applier
This commit is contained in:
48
src/Metin2Launcher/Apply/AtomicApplier.cs
Normal file
48
src/Metin2Launcher/Apply/AtomicApplier.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using Metin2Launcher.Logging;
|
||||
|
||||
namespace Metin2Launcher.Apply;
|
||||
|
||||
/// <summary>
|
||||
/// Moves staged files into their final locations.
|
||||
///
|
||||
/// Design note: this is NOT a single-transaction atomic apply over the whole set.
|
||||
/// Each individual file is moved atomically via <see cref="File.Move(string, string, bool)"/>
|
||||
/// (which on .NET 8 uses MoveFileEx with MOVEFILE_REPLACE_EXISTING on Windows, and
|
||||
/// rename(2) on Unix — both atomic at the per-file level). If the process dies mid-apply,
|
||||
/// the player ends up with a partially-applied state.
|
||||
///
|
||||
/// Acceptable for MVP because:
|
||||
/// - content-addressed blobs are idempotent, so a restart re-applies what's left
|
||||
/// - the sha256 walk on next launch detects and heals a partial apply
|
||||
/// A proper "all or nothing" apply would require shadow directory + rename-of-dir,
|
||||
/// which the manifest design explicitly does not (yet) demand.
|
||||
/// </summary>
|
||||
public static class AtomicApplier
|
||||
{
|
||||
public readonly record struct StagedFile(string StagingPath, string FinalPath);
|
||||
|
||||
/// <summary>Applies all staged files. Throws on the first failing move; already-applied files stay applied.</summary>
|
||||
public static void Apply(IEnumerable<StagedFile> files)
|
||||
{
|
||||
foreach (var f in files)
|
||||
{
|
||||
ApplyOne(f.StagingPath, f.FinalPath);
|
||||
}
|
||||
}
|
||||
|
||||
public static void ApplyOne(string stagingPath, string finalPath)
|
||||
{
|
||||
if (!File.Exists(stagingPath))
|
||||
throw new FileNotFoundException("staging file not found", stagingPath);
|
||||
|
||||
var dir = Path.GetDirectoryName(finalPath);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
// File.Move with overwrite:true is atomic on both Windows (MoveFileEx) and Unix (rename(2))
|
||||
// as long as source and destination are on the same filesystem — which they are when
|
||||
// staging lives under client/.updates/staging.
|
||||
File.Move(stagingPath, finalPath, overwrite: true);
|
||||
Log.Info($"applied {finalPath}");
|
||||
}
|
||||
}
|
||||
80
tests/Metin2Launcher.Tests/AtomicApplierTests.cs
Normal file
80
tests/Metin2Launcher.Tests/AtomicApplierTests.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
using System.Text;
|
||||
using Metin2Launcher.Apply;
|
||||
using Xunit;
|
||||
|
||||
namespace Metin2Launcher.Tests;
|
||||
|
||||
public class AtomicApplierTests : IDisposable
|
||||
{
|
||||
private readonly string _root;
|
||||
|
||||
public AtomicApplierTests()
|
||||
{
|
||||
_root = Path.Combine(Path.GetTempPath(), "metin-apply-" + Guid.NewGuid());
|
||||
Directory.CreateDirectory(_root);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_root, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyOne_replaces_existing_target()
|
||||
{
|
||||
var staging = Path.Combine(_root, "staging", "a.txt");
|
||||
var final = Path.Combine(_root, "client", "a.txt");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(staging)!);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(final)!);
|
||||
File.WriteAllText(final, "old");
|
||||
File.WriteAllText(staging, "new");
|
||||
|
||||
AtomicApplier.ApplyOne(staging, final);
|
||||
|
||||
Assert.Equal("new", File.ReadAllText(final));
|
||||
Assert.False(File.Exists(staging));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyOne_creates_missing_directory()
|
||||
{
|
||||
var staging = Path.Combine(_root, "staging", "deep", "b.bin");
|
||||
var final = Path.Combine(_root, "client", "nested", "newly", "made", "b.bin");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(staging)!);
|
||||
File.WriteAllBytes(staging, new byte[] { 1, 2, 3 });
|
||||
|
||||
AtomicApplier.ApplyOne(staging, final);
|
||||
|
||||
Assert.True(File.Exists(final));
|
||||
Assert.Equal(new byte[] { 1, 2, 3 }, File.ReadAllBytes(final));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Apply_moves_all_files_in_order()
|
||||
{
|
||||
var items = new List<AtomicApplier.StagedFile>();
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var s = Path.Combine(_root, "staging", $"f{i}.txt");
|
||||
var f = Path.Combine(_root, "client", $"f{i}.txt");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(s)!);
|
||||
File.WriteAllText(s, $"content-{i}");
|
||||
items.Add(new AtomicApplier.StagedFile(s, f));
|
||||
}
|
||||
|
||||
AtomicApplier.Apply(items);
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
Assert.Equal($"content-{i}", File.ReadAllText(items[i].FinalPath));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyOne_throws_if_staging_missing()
|
||||
{
|
||||
var staging = Path.Combine(_root, "staging", "missing.txt");
|
||||
var final = Path.Combine(_root, "client", "missing.txt");
|
||||
Assert.Throws<FileNotFoundException>(() => AtomicApplier.ApplyOne(staging, final));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user