diff --git a/src/VRCToolsDataSync.Core/Infra/GlobalMutex.cs b/src/VRCToolsDataSync.Core/Infra/GlobalMutex.cs
new file mode 100644
index 0000000..d64fd41
--- /dev/null
+++ b/src/VRCToolsDataSync.Core/Infra/GlobalMutex.cs
@@ -0,0 +1,94 @@
+using System.Security.Cryptography;
+using System.Text;
+
+namespace VRCToolsDataSync.Core.Infra;
+
+///
+/// 対話セッションをまたいで見える を作る (issue #52)。
+///
+/// 接頭辞を付けないと、名前は対話セッションごとの名前空間に作られる。同じ
+/// 利用者がユーザーの切り替えやリモートデスクトップで 2 つのセッションを持つと、
+/// 守りたい資源 (%AppData% の下や、インストール先) は共有されるのに、
+/// ロックだけが互いに見えなくなる。
+///
+///
+internal static class GlobalMutex
+{
+ ///
+ /// Global\ の名前で作る。作れない場合はセッション内だけの名前で妥協する。
+ ///
+ /// Global\ の名前を作れない構成もある (権限を落とした環境や、名前に
+ /// 区切りを許さないプラットフォーム)。そこで投げるより、同じセッションの
+ /// 重なりだけでも防げるほうがよい。
+ ///
+ ///
+ /// 断られたら、まず開き直す。 は
+ /// 「この環境では作れない」だけでなく「その名前のものが既にあり、全部の権利
+ /// では開けない」でも飛ぶ。後者でセッション内だけの名前へ落ちると、先客は
+ /// Global\ の物を、こちらは別の物を、それぞれ同時に持ててしまう。
+ /// ロックを持っているつもりで誰とも待ち合わせていない状態になり、守りたかった
+ /// 読んで書き戻す一連が並走する。 は
+ /// 待ち合わせに要る権利だけを求めるので、作れなくても開ける場合はこちらが通る。
+ ///
+ ///
+ /// 開くこともできなかったときだけ、セッション内だけの名前へ落ちる。ここは
+ /// 守り切れていない。相手 (たとえば同じ利用者の昇格したプロセス) とは
+ /// 待ち合わせられないままである。それでも落ちるのは、投げると保存そのものが
+ /// できなくなり、並走していなくても書けない側の損が確実に出るためである。
+ /// 落ちた側は、並走したときだけ失う。
+ ///
+ ///
+ /// 断られたことは覚えない。毎回試す。名前ごとの事情を環境の事情として覚えると、
+ /// 以後どの名前もセッション内だけの名前へ落ちて、黙って守れなくなる。
+ ///
+ ///
+ public static Mutex Create(string name)
+ {
+ var globalName = @"Global\" + name;
+ try
+ {
+ 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);
+ }
+ }
+
+ ///
+ /// パスを名前に使える短い鍵にする。
+ ///
+ /// パスはそのまま名前に使えない (区切りを含む) ので縮める。守る相手ごとに
+ /// 名前を分けるために使う。まとめてしまうと、無関係な相手どうしが待ち合わせる。
+ ///
+ ///
+ /// 縮める前に綴りをそろえる。ここで見ているのはファイルそのものではなく
+ /// 文字列なので、C:\dir\settings.json と C:\dir\.\settings.json
+ /// のように OS が同じ場所へ解決する綴りでも、そのままでは別の鍵になる。
+ /// 別の鍵は別のロックであり、同じ資源を守っているつもりで守れていない状態に
+ /// なる。 で絶対パスに直して区切りを
+ /// そろえ、末尾の区切りを落とし、大文字小文字を Windows のファイルシステムに
+ /// 合わせて畳む。
+ ///
+ ///
+ public static string ScopeKeyOf(string path)
+ {
+ var normalized = Path.TrimEndingDirectorySeparator(Path.GetFullPath(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..28759a2 100644
--- a/src/VRCToolsDataSync.Core/Infra/SettingsStore.cs
+++ b/src/VRCToolsDataSync.Core/Infra/SettingsStore.cs
@@ -21,12 +21,33 @@ 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 を見なくなった。同じ対話
+ // セッションで両方が同じ settings.json を保存すると、名前を変える前には
+ // 効いていた直列化が効かず、read-modify-write の取りこぼしに戻る。
+ // 別の場所に展開した旧版が残っている間だけの話だが、失うのは利用者の設定
+ // である。旧名も先に取って、その間だけ待ち合わせを保つ。
+ //
+ // 取る順は「旧名 → 新名」で固定する。新名を取るのはこの版だけで、この版は
+ // 必ず旧名から取るので、順の食い違いは起きない。
+ //
+ // 旧版が行き渡ったら消してよい。
+ private const string LegacyCrossProcessMutexName = "VRCToolsDataSync.SettingsStore.Save";
// Mutex 取得のタイムアウト。普通の Save は数十 ms で終わるため、
// これだけ待っても取れない場合は別プロセスがハング相当なので、
// 取得を諦めてプロセス内ロックだけで救済し best-effort で書く。
@@ -215,20 +236,14 @@ private void SaveInternal(
// アトミックに完結させる。プロセス内 _saveLock は同一インスタンス内の
// 並行 Save 直列化用で、別プロセスからの同時 Save は守れない。
// initiallyOwned=false でハンドルだけ作り、WaitOne でブロック取得する。
- using var crossProcessMutex = new Mutex(initiallyOwned: false, name: CrossProcessMutexName);
- bool mutexAcquired = false;
+ using var legacyMutex = new Mutex(initiallyOwned: false, name: LegacyCrossProcessMutexName);
+ using var crossProcessMutex = GlobalMutex.Create(
+ CrossProcessMutexPrefix + GlobalMutex.ScopeKeyOf(FilePath));
+ var legacyAcquired = TryEnter(legacyMutex);
+ var mutexAcquired = false;
try
{
- try
- {
- mutexAcquired = crossProcessMutex.WaitOne(CrossProcessMutexTimeout);
- }
- catch (AbandonedMutexException)
- {
- // 他プロセスが Mutex を保持したまま死んだ場合、所有権はこちらに
- // 渡ってくる。Mutex 自体は取れているので続行する。
- mutexAcquired = true;
- }
+ mutexAcquired = TryEnter(crossProcessMutex);
// タイムアウトで取れなかった場合はプロセス内ロックだけで best-effort 保存。
// 取得を諦めるよりは書き込んだ方がマシ (待つほど呼び出し元が長時間ハングする)。
@@ -290,11 +305,30 @@ 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 */ }
}
///
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/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()
{
diff --git a/tests/VRCToolsDataSync.Core.Tests/UpdateStageTests.cs b/tests/VRCToolsDataSync.Core.Tests/UpdateStageTests.cs
index a70d054..32bcc84 100644
--- a/tests/VRCToolsDataSync.Core.Tests/UpdateStageTests.cs
+++ b/tests/VRCToolsDataSync.Core.Tests/UpdateStageTests.cs
@@ -372,6 +372,49 @@ 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()));
+ 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 = "対話セッションをまたぐロックは、別スレッドから見ても同じものになる")]
+ 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()
{