diff --git a/src/VRCToolsDataSync.Core/Infra/CrossSessionFileLock.cs b/src/VRCToolsDataSync.Core/Infra/CrossSessionFileLock.cs new file mode 100644 index 0000000..28417c6 --- /dev/null +++ b/src/VRCToolsDataSync.Core/Infra/CrossSessionFileLock.cs @@ -0,0 +1,77 @@ +namespace VRCToolsDataSync.Core.Infra; + +/// +/// 守りたいファイルの隣に置いた錠前ファイルを共有無しで開いて、対話セッションを +/// またいだ排他にする (issue #81)。 +/// +/// 名前付きの と違って、名前空間の話が出てこない。ファイル +/// ハンドルの共有の指定は計算機の中で一意に効くので、どの対話セッションから +/// 開いても同じ 1 つを取り合う。Global\ を作る権限も要らない。 +/// +/// +/// 持ったまま落ちても、次の相手が待たされ続けることはない。ハンドルはプロセスの +/// 終了で OS が閉じる。錠前ファイル自体は残るが、中身は使っていないので害が無い。 +/// +/// +internal sealed class CrossSessionFileLock : IDisposable +{ + // 待ち直す間隔。最初は細かく、取れないうちは粗くする。普通の保存は数十 ms で + // 終わるので、多くの場合は 1 回目か 2 回目で取れる。 + private static readonly TimeSpan FirstRetry = TimeSpan.FromMilliseconds(5); + private static readonly TimeSpan LongestRetry = TimeSpan.FromMilliseconds(50); + + private readonly FileStream? _stream; + + private CrossSessionFileLock(FileStream? stream) + { + _stream = stream; + } + + /// 取れたかどうか。取れなくても呼び出し元は進んでよい (best-effort)。 + public bool IsHeld => _stream is not null; + + /// + /// 錠前を取る。 の間に取れなければ諦めて、 + /// 取れなかったことを で返す。 + /// + /// 諦めるのは、待ち続けると呼び出し元がその間ずっと止まるためである。普通の + /// 保存は数十 ms で終わるので、これだけ待って取れない相手はハングに近い。 + /// + /// + public static CrossSessionFileLock Acquire(string path, TimeSpan timeout) + { + var deadline = Environment.TickCount64 + (long)timeout.TotalMilliseconds; + var retry = FirstRetry; + + while (true) + { + try + { + return new CrossSessionFileLock(new FileStream( + path, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 1, + FileOptions.None)); + } + catch (IOException) + { + // 誰かが持っている。あるいは、ウイルス対策などが一時的に掴んでいる。 + // どちらも待てば空くので、待ち直す。 + } + catch (UnauthorizedAccessException) + { + // 権限や属性で開けない。待っても変わらないので、すぐ諦める。 + return new CrossSessionFileLock(null); + } + + if (Environment.TickCount64 >= deadline) return new CrossSessionFileLock(null); + + Thread.Sleep(retry); + retry = retry + retry > LongestRetry ? LongestRetry : retry + retry; + } + } + + public void Dispose() => _stream?.Dispose(); +} diff --git a/src/VRCToolsDataSync.Core/Infra/GlobalMutex.cs b/src/VRCToolsDataSync.Core/Infra/GlobalMutex.cs index d64fd41..9920126 100644 --- a/src/VRCToolsDataSync.Core/Infra/GlobalMutex.cs +++ b/src/VRCToolsDataSync.Core/Infra/GlobalMutex.cs @@ -8,9 +8,15 @@ namespace VRCToolsDataSync.Core.Infra; /// /// 接頭辞を付けないと、名前は対話セッションごとの名前空間に作られる。同じ /// 利用者がユーザーの切り替えやリモートデスクトップで 2 つのセッションを持つと、 -/// 守りたい資源 (%AppData% の下や、インストール先) は共有されるのに、 +/// 守りたい資源 (インストール先や、置き換え待ちの置き場所) は共有されるのに、 /// ロックだけが互いに見えなくなる。 /// +/// +/// 設定の保存はこれに頼らない。 へ移した +/// (issue #81)。Global\ を作れない相手や開けない相手がセッション内だけの +/// 名前へ落ちると、誰とも待ち合わせずに進む。設定はそこで利用者の入力を失う。 +/// 移行の間、旧版と待ち合わせるためにこちらも併せて取ってはいる。 +/// /// internal static class GlobalMutex { diff --git a/src/VRCToolsDataSync.Core/Infra/SettingsStore.cs b/src/VRCToolsDataSync.Core/Infra/SettingsStore.cs index 28759a2..4e37763 100644 --- a/src/VRCToolsDataSync.Core/Infra/SettingsStore.cs +++ b/src/VRCToolsDataSync.Core/Infra/SettingsStore.cs @@ -21,37 +21,48 @@ public sealed class SettingsStore private readonly object _saveLock = new(); - // クロスプロセス排他用の Named Mutex 名の接頭辞。 + // クロスプロセス排他は、設定ファイルの隣に置いた錠前ファイルで取る。 // GUI (App) と CLI が同じ settings.json に対して並走で // read-modify-write すると、プロセス内 _saveLock だけではアトミック性が // 担保できず、片方の更新が他方に潰される。 // - // 名前は対話セッションをまたいで見えるものにする (issue #52)。%AppData% は + // 排他は対話セッションをまたいで効く必要がある (issue #52)。%AppData% は // ユーザ毎に分かれるが、同じユーザの 2 つの対話セッション (ユーザーの - // 切り替えやリモートデスクトップ) からは同じ場所を指す。セッション内だけの - // 名前にしていると、そこで並走した保存を直列化できない。 + // 切り替えやリモートデスクトップ) からは同じ場所を指す。 // - // 守る相手ごとに名前を分けるため、設定ファイルのパスから引いた鍵を足す。 - // 名前を 1 つにまとめると、別のユーザどうしや、パスを指定して動かしている - // 相手 (テストなど) まで待ち合わせる。 - private const string CrossProcessMutexPrefix = "VRCToolsDataSync.SettingsStore.Save."; - - // 名前を変える前の版が使っていた名前。 - // 名前を変えた結果、旧版とこの版は互いの Mutex を見なくなった。同じ対話 - // セッションで両方が同じ settings.json を保存すると、名前を変える前には - // 効いていた直列化が効かず、read-modify-write の取りこぼしに戻る。 - // 別の場所に展開した旧版が残っている間だけの話だが、失うのは利用者の設定 - // である。旧名も先に取って、その間だけ待ち合わせを保つ。 + // 名前付き Mutex ではなくファイルを使うのは、名前空間の話を持ち込まない + // ためである (issue #81)。Global\ の名前は作るのに権限が要り、作れない相手や + // 開けない相手はセッション内だけの名前へ落ちる。落ちた側は誰とも待ち合わせて + // いないのに、待ち合わせているつもりで進む。ファイルハンドルの共有の指定は + // 計算機の中で一意に効くので、その分岐がそもそも要らない。 // - // 取る順は「旧名 → 新名」で固定する。新名を取るのはこの版だけで、この版は - // 必ず旧名から取るので、順の食い違いは起きない。 + // 守る相手ごとに分けるのも、置き場所で決まる。錠前は設定ファイルの隣に置く。 // - // 旧版が行き渡ったら消してよい。 + // 旧版と待ち合わせるための Mutex は、移行の間だけ併せて取る (下記)。 + private static string LockPathOf(string filePath) => filePath + ".lock"; + + // 錠前ファイルに移る前の 2 つの版が使っていた Mutex の名前。 + // 旧版は錠前ファイルを知らないので、錠前だけでは互いに待ち合わせない。旧版と + // この版が同じ settings.json を保存すると、以前は効いていた直列化が効かず、 + // read-modify-write の取りこぼしに戻る。失うのは利用者の設定なので、旧名も + // 取って待ち合わせを保つ。 + // + // 2 つあるのは、間に 1 度名前を変えているためである。守れる範囲が違う。 + // - Legacy: 接頭辞の無い名前。対話セッションの中でだけ見える + // - CrossSession: Global\ の名前。設定ファイルのパスから引いた鍵で分かれる + // どちらか片方では、どちらか片方の版と待ち合わせられない。 + // + // 取る順は「Legacy → CrossSession → 錠前ファイル」で固定する。旧版が取るのは + // この並びの先頭からの一部でしかないので、順の食い違いは起きない。 + // + // 旧版が行き渡ったら両方消してよい。 private const string LegacyCrossProcessMutexName = "VRCToolsDataSync.SettingsStore.Save"; - // Mutex 取得のタイムアウト。普通の Save は数十 ms で終わるため、 + private const string CrossSessionMutexPrefix = "VRCToolsDataSync.SettingsStore.Save."; + + // 錠前の取得のタイムアウト。普通の Save は数十 ms で終わるため、 // これだけ待っても取れない場合は別プロセスがハング相当なので、 // 取得を諦めてプロセス内ロックだけで救済し best-effort で書く。 - private static readonly TimeSpan CrossProcessMutexTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan CrossProcessLockTimeout = TimeSpan.FromSeconds(10); public string FilePath { get; } @@ -235,20 +246,26 @@ private void SaveInternal( // クロスプロセス排他: GUI と CLI が並走したケースで read-modify-write を // アトミックに完結させる。プロセス内 _saveLock は同一インスタンス内の // 並行 Save 直列化用で、別プロセスからの同時 Save は守れない。 - // initiallyOwned=false でハンドルだけ作り、WaitOne でブロック取得する。 + // + // 旧版と待ち合わせるための Mutex を先に取り、その中で錠前ファイルを取る。 + // 順は固定である。 using var legacyMutex = new Mutex(initiallyOwned: false, name: LegacyCrossProcessMutexName); - using var crossProcessMutex = GlobalMutex.Create( - CrossProcessMutexPrefix + GlobalMutex.ScopeKeyOf(FilePath)); + using var crossSessionMutex = GlobalMutex.Create( + CrossSessionMutexPrefix + GlobalMutex.ScopeKeyOf(FilePath)); var legacyAcquired = TryEnter(legacyMutex); - var mutexAcquired = false; + var crossSessionAcquired = false; try { - mutexAcquired = TryEnter(crossProcessMutex); + crossSessionAcquired = TryEnter(crossSessionMutex); + + using var crossProcessLock = CrossSessionFileLock.Acquire( + LockPathOf(FilePath), CrossProcessLockTimeout); - // タイムアウトで取れなかった場合はプロセス内ロックだけで best-effort 保存。 - // 取得を諦めるよりは書き込んだ方がマシ (待つほど呼び出し元が長時間ハングする)。 + // タイムアウトで取れなかった場合 (crossProcessLock.IsHeld が false) は + // プロセス内ロックだけで best-effort 保存。取得を諦めるよりは書き込んだ + // 方がマシ (待つほど呼び出し元が長時間ハングする)。 - // 一時ファイル名にも GUID を付けて、Mutex 取得失敗時の best-effort 書き込みや + // 一時ファイル名にも GUID を付けて、錠前を取れなかったときの best-effort 書き込みや // 他プロセスからの同時書き込みでも tmp 衝突しないようにする。 lock (_saveLock) { @@ -306,7 +323,7 @@ private void SaveInternal( finally { // 取った順の逆に返す。 - Exit(crossProcessMutex, mutexAcquired); + Exit(crossSessionMutex, crossSessionAcquired); Exit(legacyMutex, legacyAcquired); } } @@ -315,7 +332,7 @@ private static bool TryEnter(Mutex mutex) { try { - return mutex.WaitOne(CrossProcessMutexTimeout); + return mutex.WaitOne(CrossProcessLockTimeout); } catch (AbandonedMutexException) { diff --git a/tests/VRCToolsDataSync.Core.Tests/CrossSessionFileLockTests.cs b/tests/VRCToolsDataSync.Core.Tests/CrossSessionFileLockTests.cs new file mode 100644 index 0000000..99665fb --- /dev/null +++ b/tests/VRCToolsDataSync.Core.Tests/CrossSessionFileLockTests.cs @@ -0,0 +1,89 @@ +using VRCToolsDataSync.Core.Infra; +using Xunit; + +namespace VRCToolsDataSync.Core.Tests; + +/// +/// 錠前ファイルによる排他を固定する (issue #81)。 +/// +/// 対話セッションをまたいだ確認には環境が要るので、ここで見るのは +/// 「同時に 2 つ取れないこと」「返せば取れること」「取れないときに戻ってくること」 +/// の 3 つである。セッションをまたいでも効くことは、名前空間の話が出てこない +/// 仕組みそのものから来ている。 +/// +/// +public sealed class CrossSessionFileLockTests : IDisposable +{ + private readonly string _directory = + Path.Combine(Path.GetTempPath(), "vrctoolsdatasync-tests-" + Guid.NewGuid().ToString("N")); + + public CrossSessionFileLockTests() + { + Directory.CreateDirectory(_directory); + } + + private string LockPath => Path.Combine(_directory, "settings.json.lock"); + + public void Dispose() + { + try { Directory.Delete(_directory, recursive: true); } catch { /* best-effort */ } + } + + [Fact(DisplayName = "取った錠前は、返すまで他から取れない")] + public void SecondAcquireWaitsWhileTheFirstIsHeld() + { + using var held = CrossSessionFileLock.Acquire(LockPath, TimeSpan.FromSeconds(5)); + Assert.True(held.IsHeld); + + using var second = CrossSessionFileLock.Acquire(LockPath, TimeSpan.FromMilliseconds(200)); + Assert.False(second.IsHeld); + } + + [Fact(DisplayName = "返した錠前は、次の相手が取れる")] + public void AcquiresAfterTheHolderReleases() + { + var first = CrossSessionFileLock.Acquire(LockPath, TimeSpan.FromSeconds(5)); + Assert.True(first.IsHeld); + first.Dispose(); + + using var second = CrossSessionFileLock.Acquire(LockPath, TimeSpan.FromSeconds(5)); + Assert.True(second.IsHeld); + } + + [Fact(DisplayName = "取れないときは、待ち続けずに戻ってくる")] + public void GivesUpWithinTheTimeout() + { + using var held = CrossSessionFileLock.Acquire(LockPath, TimeSpan.FromSeconds(5)); + Assert.True(held.IsHeld); + + // 待ち続けると呼び出し元がその間ずっと止まる。諦めて戻ることを見る。 + var waited = System.Diagnostics.Stopwatch.StartNew(); + using var second = CrossSessionFileLock.Acquire(LockPath, TimeSpan.FromMilliseconds(300)); + waited.Stop(); + + Assert.False(second.IsHeld); + Assert.True(waited.Elapsed < TimeSpan.FromSeconds(5), $"戻るまでに {waited.Elapsed} かかった"); + } + + [Fact(DisplayName = "前回の錠前ファイルが残っていても取れる")] + public void AcquiresWhenTheLockFileIsLeftBehind() + { + // 持ったまま落ちてもハンドルは OS が閉じる。残るのはファイルだけで、 + // それが次の相手を締め出してはいけない。 + File.WriteAllText(LockPath, string.Empty); + + using var lockFile = CrossSessionFileLock.Acquire(LockPath, TimeSpan.FromSeconds(5)); + Assert.True(lockFile.IsHeld); + } + + [Fact(DisplayName = "別のファイルの錠前とは、互いに待たない")] + public void LocksForDifferentFilesDoNotWaitOnEachOther() + { + using var mine = CrossSessionFileLock.Acquire(LockPath, TimeSpan.FromSeconds(5)); + using var other = CrossSessionFileLock.Acquire( + Path.Combine(_directory, "elsewhere.json.lock"), TimeSpan.FromSeconds(5)); + + Assert.True(mine.IsHeld); + Assert.True(other.IsHeld); + } +} diff --git a/tests/VRCToolsDataSync.Core.Tests/UpdateStageTests.cs b/tests/VRCToolsDataSync.Core.Tests/UpdateStageTests.cs index 32bcc84..b95eaf9 100644 --- a/tests/VRCToolsDataSync.Core.Tests/UpdateStageTests.cs +++ b/tests/VRCToolsDataSync.Core.Tests/UpdateStageTests.cs @@ -372,28 +372,28 @@ public void AppliesWhenInstalledVersionIsOlder() Assert.Equal("0.0.10", staged!.Tag); } - [Fact(DisplayName = "設定の保存のロックは、設定ファイルごとに分かれる")] - public void SettingsSaveLockIsScopedPerSettingsFile() + [Fact(DisplayName = "ロックの鍵は、守る場所ごとに分かれる")] + public void ScopeKeyIsScopedPerPath() { - // 名前を 1 つにまとめると、別のユーザどうしや、パスを指定して動かして - // いる相手まで待ち合わせる。守る相手はファイルなので、そこで分ける。 - var mine = Path.Combine(_directory, "settings.json"); - var other = Path.Combine(_directory, "elsewhere", "settings.json"); + // 名前を 1 つにまとめると、別のインストール先どうしまで待ち合わせる。 + // 守る相手は場所なので、そこで分ける。 + var mine = Path.Combine(_directory, "install"); + var other = Path.Combine(_directory, "elsewhere", "install"); Assert.NotEqual(GlobalMutex.ScopeKeyOf(mine), GlobalMutex.ScopeKeyOf(other)); - // 同じファイルを別の綴りで指した相手と待ち合わせられないと意味が無い。 + // 同じ場所を別の綴りで指した相手と待ち合わせられないと意味が無い。 // 大文字小文字は Windows のファイルシステムに合わせて畳み、綴りは // 絶対パスにそろえる。 Assert.Equal(GlobalMutex.ScopeKeyOf(mine), GlobalMutex.ScopeKeyOf(mine.ToUpperInvariant())); Assert.Equal( GlobalMutex.ScopeKeyOf(mine), - GlobalMutex.ScopeKeyOf(Path.Combine(_directory, ".", "settings.json"))); + GlobalMutex.ScopeKeyOf(Path.Combine(_directory, ".", "install"))); Assert.Equal( GlobalMutex.ScopeKeyOf(mine), - GlobalMutex.ScopeKeyOf(Path.Combine(_directory, "elsewhere", "..", "settings.json"))); + GlobalMutex.ScopeKeyOf(Path.Combine(_directory, "elsewhere", "..", "install"))); - // Windows では区切りがどちらでも同じファイルを指す。 + // Windows では区切りがどちらでも同じ場所を指す。 Assert.Equal(GlobalMutex.ScopeKeyOf(mine), GlobalMutex.ScopeKeyOf(mine.Replace('\\', '/'))); }