From cf73f28b694e838199c4833bd6e5743856f22ce0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 07:53:22 +0000 Subject: [PATCH 1/3] =?UTF-8?q?fix(settings):=20=E8=A8=AD=E5=AE=9A?= =?UTF-8?q?=E3=81=AE=E4=BF=9D=E5=AD=98=E3=81=AE=E6=8E=92=E4=BB=96=E3=82=92?= =?UTF-8?q?=E5=AF=BE=E8=A9=B1=E3=82=BB=E3=83=83=E3=82=B7=E3=83=A7=E3=83=B3?= =?UTF-8?q?=E3=82=92=E3=81=BE=E3=81=9F=E3=81=90=E5=90=8D=E5=89=8D=E3=81=AB?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 設定の保存のクロスプロセス排他は、接頭辞の無い名前を使っていた。コメントは 「settings は %AppData% 配下なのでユーザ毎にしか共有されない」を理由に挙げて いたが、この前提が誤っている。 %AppData% はユーザ毎に分かれるが、同じユーザの 2 つの対話セッション (ユーザーの切り替えやリモートデスクトップ) からは同じ場所を指す。一方で接頭辞 の無い名前はセッションごとの名前空間に作られるため、そこで並走した保存を直列化 できない。両セッションが同じ settings.json を read-modify-write すると、片方の 更新が黙って潰される。 名前を Global\ にした。守る相手ごとに分けるため、設定ファイルのパスから引いた 鍵を足す。1 つにまとめると、別のユーザどうしや、パスを指定して動かしている相手 (テストなど) まで待ち合わせる。 Global\ を作れない環境向けのフォールバックは #48 が既に持っていたので、鍵の 作り方と一緒に GlobalMutex へ寄せ、UpdateStage もそちらを使うようにした。 作れなかったことは覚えない。UnauthorizedAccessException は「この環境では作れ ない」だけでなく「その名前のものが既にあり、開く権利が無い」でも飛ぶ。名前ごと の事情を環境の事情として覚えると、以後どの名前もセッション内だけの名前へ落ちて、 黙って守れなくなる。 Refs #52 --- .../Infra/GlobalMutex.cs | 58 +++++++++++++++++++ .../Infra/SettingsStore.cs | 19 ++++-- .../Infra/UpdateStage.cs | 26 +-------- .../UpdateStageTests.cs | 33 +++++++++++ 4 files changed, 108 insertions(+), 28 deletions(-) create mode 100644 src/VRCToolsDataSync.Core/Infra/GlobalMutex.cs diff --git a/src/VRCToolsDataSync.Core/Infra/GlobalMutex.cs b/src/VRCToolsDataSync.Core/Infra/GlobalMutex.cs new file mode 100644 index 0000000..0ba1178 --- /dev/null +++ b/src/VRCToolsDataSync.Core/Infra/GlobalMutex.cs @@ -0,0 +1,58 @@ +using System.Security.Cryptography; +using System.Text; + +namespace VRCToolsDataSync.Core.Infra; + +/// +/// 対話セッションをまたいで見える を作る (issue #52)。 +/// +/// 接頭辞を付けないと、名前は対話セッションごとの名前空間に作られる。同じ +/// 利用者がユーザーの切り替えやリモートデスクトップで 2 つのセッションを持つと、 +/// 守りたい資源 (%AppData% の下や、インストール先) は共有されるのに、 +/// ロックだけが互いに見えなくなる。 +/// +/// +internal static class GlobalMutex +{ + /// + /// Global\ の名前で作る。作れない場合はセッション内だけの名前で妥協する。 + /// + /// Global\ の名前を作れない構成もある (権限を落とした環境や、名前に + /// 区切りを許さないプラットフォーム)。そこで投げるより、同じセッションの + /// 重なりだけでも防げるほうがよい。 + /// + /// + /// 作れなかったことは覚えない。毎回試す。 + /// は「この環境では作れない」だけでなく「その名前のものが既にあり、開く権利が + /// 無い」でも飛ぶ。名前ごとの事情を環境の事情として覚えると、以後どの名前も + /// セッション内だけの名前へ落ちて、黙って守れなくなる。例外の往復より、 + /// 取り違えないことを採る。 + /// + /// + public static Mutex Create(string name) + { + try + { + return new Mutex(initiallyOwned: false, name: @"Global\" + name); + } + catch (Exception ex) when (ex is UnauthorizedAccessException or IOException or NotSupportedException) + { + return new Mutex(initiallyOwned: false, name: name); + } + } + + /// + /// パスを名前に使える短い鍵にする。 + /// + /// パスはそのまま名前に使えない (区切りを含む) ので縮める。大文字小文字は + /// Windows のファイルシステムに合わせて畳む。守る相手ごとに名前を分けるため + /// に使う。まとめてしまうと、無関係な相手どうしが待ち合わせる。 + /// + /// + public static string ScopeKeyOf(string path) + { + var normalized = Path.TrimEndingDirectorySeparator(path).ToLowerInvariant(); + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(normalized)); + return Convert.ToHexStringLower(hash)[..16]; + } +} diff --git a/src/VRCToolsDataSync.Core/Infra/SettingsStore.cs b/src/VRCToolsDataSync.Core/Infra/SettingsStore.cs index f8af8c7..1ef7ad1 100644 --- a/src/VRCToolsDataSync.Core/Infra/SettingsStore.cs +++ b/src/VRCToolsDataSync.Core/Infra/SettingsStore.cs @@ -21,12 +21,20 @@ public sealed class SettingsStore private readonly object _saveLock = new(); - // クロスプロセス排他用の Named Mutex 名。 + // クロスプロセス排他用の Named Mutex 名の接頭辞。 // GUI (App) と CLI が同じ settings.json に対して並走で // read-modify-write すると、プロセス内 _saveLock だけではアトミック性が - // 担保できず、片方の更新が他方に潰される。Global\ は付けずユーザセッション - // 内のみ排他にする (settings は %AppData% 配下なのでユーザ毎にしか共有されない)。 - private const string CrossProcessMutexName = "VRCToolsDataSync.SettingsStore.Save"; + // 担保できず、片方の更新が他方に潰される。 + // + // 名前は対話セッションをまたいで見えるものにする (issue #52)。%AppData% は + // ユーザ毎に分かれるが、同じユーザの 2 つの対話セッション (ユーザーの + // 切り替えやリモートデスクトップ) からは同じ場所を指す。セッション内だけの + // 名前にしていると、そこで並走した保存を直列化できない。 + // + // 守る相手ごとに名前を分けるため、設定ファイルのパスから引いた鍵を足す。 + // 名前を 1 つにまとめると、別のユーザどうしや、パスを指定して動かしている + // 相手 (テストなど) まで待ち合わせる。 + private const string CrossProcessMutexPrefix = "VRCToolsDataSync.SettingsStore.Save."; // Mutex 取得のタイムアウト。普通の Save は数十 ms で終わるため、 // これだけ待っても取れない場合は別プロセスがハング相当なので、 // 取得を諦めてプロセス内ロックだけで救済し best-effort で書く。 @@ -215,7 +223,8 @@ private void SaveInternal( // アトミックに完結させる。プロセス内 _saveLock は同一インスタンス内の // 並行 Save 直列化用で、別プロセスからの同時 Save は守れない。 // initiallyOwned=false でハンドルだけ作り、WaitOne でブロック取得する。 - using var crossProcessMutex = new Mutex(initiallyOwned: false, name: CrossProcessMutexName); + using var crossProcessMutex = GlobalMutex.Create( + CrossProcessMutexPrefix + GlobalMutex.ScopeKeyOf(FilePath)); bool mutexAcquired = false; try { diff --git a/src/VRCToolsDataSync.Core/Infra/UpdateStage.cs b/src/VRCToolsDataSync.Core/Infra/UpdateStage.cs index 627ca2f..bb9323e 100644 --- a/src/VRCToolsDataSync.Core/Infra/UpdateStage.cs +++ b/src/VRCToolsDataSync.Core/Infra/UpdateStage.cs @@ -1,6 +1,5 @@ using System.Globalization; using System.Security.Cryptography; -using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; @@ -155,15 +154,9 @@ public static string DirectoryFor(string? installRoot) } /// - /// インストール先をディレクトリ名にする。パスはそのまま使えないので縮める。 - /// 大文字小文字は Windows のファイルシステムに合わせて畳む。 + /// インストール先をディレクトリ名にする。ロックの名前にも同じ鍵を使う。 /// - private static string ScopeKeyOf(string installRoot) - { - var normalized = Path.TrimEndingDirectorySeparator(installRoot).ToLowerInvariant(); - var hash = SHA256.HashData(Encoding.UTF8.GetBytes(normalized)); - return Convert.ToHexStringLower(hash)[..16]; - } + private static string ScopeKeyOf(string installRoot) => GlobalMutex.ScopeKeyOf(installRoot); /// /// 取得しておいた ZIP () を、記録と一緒に @@ -272,20 +265,7 @@ public static Mutex CreateApplyMutex(string? installRoot) /// インストール先ごとに分けた Global\ を作る。 /// private static Mutex CreateScopedMutex(string prefix, string? installRoot) - { - var name = prefix + (installRoot is null ? "local" : ScopeKeyOf(installRoot)); - try - { - return new Mutex(initiallyOwned: false, name: @"Global\" + name); - } - catch (Exception ex) when (ex is UnauthorizedAccessException or IOException or NotSupportedException) - { - // Global\ の名前を作れない構成もある (権限を落とした環境や、 - // 名前に区切りを許さないプラットフォーム)。そこではセッション内 - // だけの名前で妥協する。同じセッションの重なりは防げる。 - return new Mutex(initiallyOwned: false, name: name); - } - } + => GlobalMutex.Create(prefix + (installRoot is null ? "local" : ScopeKeyOf(installRoot))); /// /// 更新を取得している間だけ握るクロスプロセスのロック (issue #52)。 diff --git a/tests/VRCToolsDataSync.Core.Tests/UpdateStageTests.cs b/tests/VRCToolsDataSync.Core.Tests/UpdateStageTests.cs index a70d054..8d8e09c 100644 --- a/tests/VRCToolsDataSync.Core.Tests/UpdateStageTests.cs +++ b/tests/VRCToolsDataSync.Core.Tests/UpdateStageTests.cs @@ -372,6 +372,39 @@ public void AppliesWhenInstalledVersionIsOlder() Assert.Equal("0.0.10", staged!.Tag); } + [Fact(DisplayName = "設定の保存のロックは、設定ファイルごとに分かれる")] + public void SettingsSaveLockIsScopedPerSettingsFile() + { + // 名前を 1 つにまとめると、別のユーザどうしや、パスを指定して動かして + // いる相手まで待ち合わせる。守る相手はファイルなので、そこで分ける。 + var mine = Path.Combine(_directory, "settings.json"); + var other = Path.Combine(_directory, "elsewhere", "settings.json"); + + Assert.NotEqual(GlobalMutex.ScopeKeyOf(mine), GlobalMutex.ScopeKeyOf(other)); + + // 大文字小文字は Windows のファイルシステムに合わせて畳む。同じ + // ファイルを別の綴りで指した相手と待ち合わせられないと意味が無い。 + Assert.Equal(GlobalMutex.ScopeKeyOf(mine), GlobalMutex.ScopeKeyOf(mine.ToUpperInvariant())); + } + + [Fact(DisplayName = "対話セッションをまたぐロックは、別スレッドから見ても同じものになる")] + public void GlobalMutexIsVisibleAcrossThreads() + { + var name = "vrctoolsdatasync-tests-" + Guid.NewGuid().ToString("N"); + + using var held = GlobalMutex.Create(name); + Assert.True(held.WaitOne(0)); + try + { + Assert.False(CanHoldOnAnotherThread(name, GlobalMutex.Create)); + Assert.True(CanHoldOnAnotherThread(name + "-other", GlobalMutex.Create)); + } + finally + { + held.ReleaseMutex(); + } + } + [Fact(DisplayName = "起動できなかった配布物は、タグと digest の組で覚える")] public void RemembersTheDistributionThatCouldNotStart() { From aeb212d52cf1ca067242a189af92e1db36addc25 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 08:50:25 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix(update):=20=E3=83=AD=E3=83=83=E3=82=AF?= =?UTF-8?q?=E3=81=AE=E9=8D=B5=E3=82=92=E4=BD=9C=E3=82=8B=E5=89=8D=E3=81=AB?= =?UTF-8?q?=E3=83=91=E3=82=B9=E3=81=AE=E7=B6=B4=E3=82=8A=E3=82=92=E3=81=9D?= =?UTF-8?q?=E3=82=8D=E3=81=88=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ScopeKeyOf が見ているのはファイルではなく文字列なので、C:\dir\settings.json と C:\dir\.\settings.json のように OS が同じ場所へ解決する綴りでも、そのままでは 別の鍵になっていた。別の鍵は別のロックであり、同じ資源を守っているつもりで 守れていない。設定の保存では、これが read-modify-write の取りこぼしに戻る。 Path.GetFullPath で絶対パスに直してから縮める。区切りの違いと . / .. も ここでそろう。既に絶対パスで正規化済みのもの (AppContext.BaseDirectory から 引くインストール先) には効かないので、置き換え待ちの置き場所の名前は動かない。 Refs #52 --- src/VRCToolsDataSync.Core/Infra/GlobalMutex.cs | 16 ++++++++++++---- .../UpdateStageTests.cs | 14 ++++++++++++-- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/VRCToolsDataSync.Core/Infra/GlobalMutex.cs b/src/VRCToolsDataSync.Core/Infra/GlobalMutex.cs index 0ba1178..0b3877d 100644 --- a/src/VRCToolsDataSync.Core/Infra/GlobalMutex.cs +++ b/src/VRCToolsDataSync.Core/Infra/GlobalMutex.cs @@ -44,14 +44,22 @@ public static Mutex Create(string name) /// /// パスを名前に使える短い鍵にする。 /// - /// パスはそのまま名前に使えない (区切りを含む) ので縮める。大文字小文字は - /// Windows のファイルシステムに合わせて畳む。守る相手ごとに名前を分けるため - /// に使う。まとめてしまうと、無関係な相手どうしが待ち合わせる。 + /// パスはそのまま名前に使えない (区切りを含む) ので縮める。守る相手ごとに + /// 名前を分けるために使う。まとめてしまうと、無関係な相手どうしが待ち合わせる。 + /// + /// + /// 縮める前に綴りをそろえる。ここで見ているのはファイルそのものではなく + /// 文字列なので、C:\dir\settings.jsonC:\dir\.\settings.json + /// のように OS が同じ場所へ解決する綴りでも、そのままでは別の鍵になる。 + /// 別の鍵は別のロックであり、同じ資源を守っているつもりで守れていない状態に + /// なる。 で絶対パスに直して区切りを + /// そろえ、末尾の区切りを落とし、大文字小文字を Windows のファイルシステムに + /// 合わせて畳む。 /// /// public static string ScopeKeyOf(string path) { - var normalized = Path.TrimEndingDirectorySeparator(path).ToLowerInvariant(); + var normalized = Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)).ToLowerInvariant(); var hash = SHA256.HashData(Encoding.UTF8.GetBytes(normalized)); return Convert.ToHexStringLower(hash)[..16]; } diff --git a/tests/VRCToolsDataSync.Core.Tests/UpdateStageTests.cs b/tests/VRCToolsDataSync.Core.Tests/UpdateStageTests.cs index 8d8e09c..32bcc84 100644 --- a/tests/VRCToolsDataSync.Core.Tests/UpdateStageTests.cs +++ b/tests/VRCToolsDataSync.Core.Tests/UpdateStageTests.cs @@ -382,9 +382,19 @@ public void SettingsSaveLockIsScopedPerSettingsFile() Assert.NotEqual(GlobalMutex.ScopeKeyOf(mine), GlobalMutex.ScopeKeyOf(other)); - // 大文字小文字は Windows のファイルシステムに合わせて畳む。同じ - // ファイルを別の綴りで指した相手と待ち合わせられないと意味が無い。 + // 同じファイルを別の綴りで指した相手と待ち合わせられないと意味が無い。 + // 大文字小文字は Windows のファイルシステムに合わせて畳み、綴りは + // 絶対パスにそろえる。 Assert.Equal(GlobalMutex.ScopeKeyOf(mine), GlobalMutex.ScopeKeyOf(mine.ToUpperInvariant())); + Assert.Equal( + GlobalMutex.ScopeKeyOf(mine), + GlobalMutex.ScopeKeyOf(Path.Combine(_directory, ".", "settings.json"))); + Assert.Equal( + GlobalMutex.ScopeKeyOf(mine), + GlobalMutex.ScopeKeyOf(Path.Combine(_directory, "elsewhere", "..", "settings.json"))); + + // Windows では区切りがどちらでも同じファイルを指す。 + Assert.Equal(GlobalMutex.ScopeKeyOf(mine), GlobalMutex.ScopeKeyOf(mine.Replace('\\', '/'))); } [Fact(DisplayName = "対話セッションをまたぐロックは、別スレッドから見ても同じものになる")] From 33d74948aba83f469ab9ff3700ff39a4cdf69330 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 09:02:05 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix(settings):=20=E6=97=A7=E7=89=88?= =?UTF-8?q?=E3=81=A8=E5=BE=85=E3=81=A1=E5=90=88=E3=82=8F=E3=81=9B=E3=82=8B?= =?UTF-8?q?=E5=90=8D=E5=89=8D=E3=82=92=E6=AE=8B=E3=81=97=E3=80=81=E9=96=8B?= =?UTF-8?q?=E3=81=91=E3=82=8B=E3=83=AD=E3=83=83=E3=82=AF=E3=81=AF=E9=96=8B?= =?UTF-8?q?=E3=81=8D=E7=9B=B4=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 名前を変えた結果、この版は旧版の Mutex を見なくなった。同じ対話セッションで 別の場所の旧版と同じ settings.json を保存すると、名前を変える前には効いていた 直列化が効かず、read-modify-write の取りこぼしに戻る。旧名も「旧名 → 新名」の 順で取り、旧版が残っている間の待ち合わせを保つ。新名を取るのはこの版だけで、 この版は必ず旧名から取るので、順の食い違いは起きない。 もう 1 点、断られたときに別の名前へ落ちるのが早すぎた。 UnauthorizedAccessException は「作れない環境」だけでなく「その名前のものが既に あり、全部の権利では開けない」でも飛ぶ。後者でセッション内だけの名前へ落ちると、 先客は Global\ の物を、こちらは別の物を、それぞれ同時に持ててしまう。ロックを 持っているつもりで誰とも待ち合わせていない。Mutex.OpenExisting は待ち合わせに 要る権利だけを求めるので、まずこれで開き直す。 開くこともできなかったときだけ、これまで通りセッション内だけの名前へ落ちる。 そこは守り切れていない。投げると保存そのものができなくなり、並走していなくても 書けない側の損が確実に出る。落ちた側は、並走したときだけ失う。 Refs #52 --- .../Infra/GlobalMutex.cs | 40 ++++++++++++-- .../Infra/SettingsStore.cs | 55 ++++++++++++++----- .../SettingsStoreUpdateSectionTests.cs | 36 ++++++++++++ 3 files changed, 110 insertions(+), 21 deletions(-) diff --git a/src/VRCToolsDataSync.Core/Infra/GlobalMutex.cs b/src/VRCToolsDataSync.Core/Infra/GlobalMutex.cs index 0b3877d..d64fd41 100644 --- a/src/VRCToolsDataSync.Core/Infra/GlobalMutex.cs +++ b/src/VRCToolsDataSync.Core/Infra/GlobalMutex.cs @@ -22,21 +22,49 @@ internal static class GlobalMutex /// 重なりだけでも防げるほうがよい。 /// /// - /// 作れなかったことは覚えない。毎回試す。 - /// は「この環境では作れない」だけでなく「その名前のものが既にあり、開く権利が - /// 無い」でも飛ぶ。名前ごとの事情を環境の事情として覚えると、以後どの名前も - /// セッション内だけの名前へ落ちて、黙って守れなくなる。例外の往復より、 - /// 取り違えないことを採る。 + /// 断られたら、まず開き直す。 は + /// 「この環境では作れない」だけでなく「その名前のものが既にあり、全部の権利 + /// では開けない」でも飛ぶ。後者でセッション内だけの名前へ落ちると、先客は + /// Global\ の物を、こちらは別の物を、それぞれ同時に持ててしまう。 + /// ロックを持っているつもりで誰とも待ち合わせていない状態になり、守りたかった + /// 読んで書き戻す一連が並走する。 は + /// 待ち合わせに要る権利だけを求めるので、作れなくても開ける場合はこちらが通る。 + /// + /// + /// 開くこともできなかったときだけ、セッション内だけの名前へ落ちる。ここは + /// 守り切れていない。相手 (たとえば同じ利用者の昇格したプロセス) とは + /// 待ち合わせられないままである。それでも落ちるのは、投げると保存そのものが + /// できなくなり、並走していなくても書けない側の損が確実に出るためである。 + /// 落ちた側は、並走したときだけ失う。 + /// + /// + /// 断られたことは覚えない。毎回試す。名前ごとの事情を環境の事情として覚えると、 + /// 以後どの名前もセッション内だけの名前へ落ちて、黙って守れなくなる。 /// /// public static Mutex Create(string name) { + var globalName = @"Global\" + name; try { - return new Mutex(initiallyOwned: false, name: @"Global\" + name); + return new Mutex(initiallyOwned: false, name: globalName); } catch (Exception ex) when (ex is UnauthorizedAccessException or IOException or NotSupportedException) { + try + { + // 待ち合わせに要る権利 (Synchronize と Modify) だけで開く。 + return Mutex.OpenExisting(globalName); + } + catch (WaitHandleCannotBeOpenedException) + { + // 物が無い。つまり断られたのは権限であって、この環境では作れない。 + } + catch (Exception inner) when (inner is UnauthorizedAccessException or IOException or NotSupportedException) + { + // 物はあるが、開くこともできない。 + } + return new Mutex(initiallyOwned: false, name: name); } } diff --git a/src/VRCToolsDataSync.Core/Infra/SettingsStore.cs b/src/VRCToolsDataSync.Core/Infra/SettingsStore.cs index 1ef7ad1..28759a2 100644 --- a/src/VRCToolsDataSync.Core/Infra/SettingsStore.cs +++ b/src/VRCToolsDataSync.Core/Infra/SettingsStore.cs @@ -35,6 +35,19 @@ public sealed class SettingsStore // 名前を 1 つにまとめると、別のユーザどうしや、パスを指定して動かしている // 相手 (テストなど) まで待ち合わせる。 private const string CrossProcessMutexPrefix = "VRCToolsDataSync.SettingsStore.Save."; + + // 名前を変える前の版が使っていた名前。 + // 名前を変えた結果、旧版とこの版は互いの Mutex を見なくなった。同じ対話 + // セッションで両方が同じ settings.json を保存すると、名前を変える前には + // 効いていた直列化が効かず、read-modify-write の取りこぼしに戻る。 + // 別の場所に展開した旧版が残っている間だけの話だが、失うのは利用者の設定 + // である。旧名も先に取って、その間だけ待ち合わせを保つ。 + // + // 取る順は「旧名 → 新名」で固定する。新名を取るのはこの版だけで、この版は + // 必ず旧名から取るので、順の食い違いは起きない。 + // + // 旧版が行き渡ったら消してよい。 + private const string LegacyCrossProcessMutexName = "VRCToolsDataSync.SettingsStore.Save"; // Mutex 取得のタイムアウト。普通の Save は数十 ms で終わるため、 // これだけ待っても取れない場合は別プロセスがハング相当なので、 // 取得を諦めてプロセス内ロックだけで救済し best-effort で書く。 @@ -223,21 +236,14 @@ private void SaveInternal( // アトミックに完結させる。プロセス内 _saveLock は同一インスタンス内の // 並行 Save 直列化用で、別プロセスからの同時 Save は守れない。 // initiallyOwned=false でハンドルだけ作り、WaitOne でブロック取得する。 + using var legacyMutex = new Mutex(initiallyOwned: false, name: LegacyCrossProcessMutexName); using var crossProcessMutex = GlobalMutex.Create( CrossProcessMutexPrefix + GlobalMutex.ScopeKeyOf(FilePath)); - bool mutexAcquired = false; + var legacyAcquired = TryEnter(legacyMutex); + var mutexAcquired = false; try { - try - { - mutexAcquired = crossProcessMutex.WaitOne(CrossProcessMutexTimeout); - } - catch (AbandonedMutexException) - { - // 他プロセスが Mutex を保持したまま死んだ場合、所有権はこちらに - // 渡ってくる。Mutex 自体は取れているので続行する。 - mutexAcquired = true; - } + mutexAcquired = TryEnter(crossProcessMutex); // タイムアウトで取れなかった場合はプロセス内ロックだけで best-effort 保存。 // 取得を諦めるよりは書き込んだ方がマシ (待つほど呼び出し元が長時間ハングする)。 @@ -299,13 +305,32 @@ private void SaveInternal( } finally { - if (mutexAcquired) - { - try { crossProcessMutex.ReleaseMutex(); } catch { /* best-effort */ } - } + // 取った順の逆に返す。 + Exit(crossProcessMutex, mutexAcquired); + Exit(legacyMutex, legacyAcquired); } } + private static bool TryEnter(Mutex mutex) + { + try + { + return mutex.WaitOne(CrossProcessMutexTimeout); + } + catch (AbandonedMutexException) + { + // 他プロセスが Mutex を保持したまま死んだ場合、所有権はこちらに + // 渡ってくる。Mutex 自体は取れているので続行する。 + return true; + } + } + + private static void Exit(Mutex mutex, bool acquired) + { + if (!acquired) return; + try { mutex.ReleaseMutex(); } catch { /* best-effort */ } + } + /// /// 呼び出し元の とディスクの現行 settings を /// マージした結果を返す。 diff --git a/tests/VRCToolsDataSync.Core.Tests/SettingsStoreUpdateSectionTests.cs b/tests/VRCToolsDataSync.Core.Tests/SettingsStoreUpdateSectionTests.cs index 6654b0b..7cf84e6 100644 --- a/tests/VRCToolsDataSync.Core.Tests/SettingsStoreUpdateSectionTests.cs +++ b/tests/VRCToolsDataSync.Core.Tests/SettingsStoreUpdateSectionTests.cs @@ -21,6 +21,42 @@ public void Dispose() try { Directory.Delete(_directory, recursive: true); } catch { /* best-effort */ } } + [Fact(DisplayName = "別々の settings.json への保存が並走しても、どちらも書き上がる")] + public void ConcurrentSavesToDifferentFilesBothComplete() + { + // 保存は 2 つの Mutex を「旧名 → 新名」の順で取る。旧名は設定ファイルで + // 分かれないので、別のファイルへ保存する相手ともここで待ち合わせる。 + // 順が食い違えば互いに待ち続けるため、並走させて抜けられることを見る。 + var first = new SettingsStore(Path.Combine(_directory, "first", "settings.json")); + var second = new SettingsStore(Path.Combine(_directory, "second", "settings.json")); + + var saves = new[] + { + Task.Run(() => + { + for (var i = 0; i < 20; i++) + { + var settings = first.Load(); + settings.Update.NotifiedVersion = "0.0.10"; + first.Save(settings); + } + }), + Task.Run(() => + { + for (var i = 0; i < 20; i++) + { + var settings = second.Load(); + settings.Update.NotifiedVersion = "0.0.11"; + second.Save(settings); + } + }), + }; + + Assert.True(Task.WhenAll(saves).Wait(TimeSpan.FromSeconds(30)), "保存が終わらなかった"); + Assert.Equal("0.0.10", first.Load().Update.NotifiedVersion); + Assert.Equal("0.0.11", second.Load().Update.NotifiedVersion); + } + [Fact(DisplayName = "update セクションは保存と読み込みで往復する")] public void RoundTripsUpdateSection() {