launcher: add atomic staging-to-final applier

This commit is contained in:
Jan Nedbal
2026-04-14 11:12:41 +02:00
parent 80e1450df9
commit ae33470f7f
2 changed files with 128 additions and 0 deletions

View 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}");
}
}

View 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));
}
}