From 736d2136a439e12f3f0f4fe48110838c23852ea0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 06:19:50 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat(update):=20=E7=BD=AE=E3=81=8D=E6=8F=9B?= =?UTF-8?q?=E3=81=88=E3=81=9F=20App=20=E3=81=8C=E8=B5=B7=E5=8B=95=E3=81=A7?= =?UTF-8?q?=E3=81=8D=E3=81=AA=E3=81=91=E3=82=8C=E3=81=B0=E9=80=80=E9=81=BF?= =?UTF-8?q?=E3=81=97=E3=81=9F=E4=B8=80=E5=BC=8F=E3=81=B8=E6=88=BB=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 更新の適用は、展開した一式を正規の位置へ入れ替えて App を起動し直すところで 終わっていた。起動し直した App が立ち上がったかどうかは見ていない。 digest が保証するのは「公開されている配布物そのものを取れたこと」までであって、 中身が起動できることではない。起動には hostfxr.dll などのランタイムも要るが、 必須ファイルを名前で数え上げる方針では一覧が数百件になり、増えるたびに追随 しなければならない。そもそも「起動できるか」を名前の一覧で近似すること自体に 無理がある。 ヘルパが起こした App を数秒見る形にした。落ちていれば退避しておいた一式へ 戻し、取得を捨てて現行版で開き直す。 早く終わること自体は異常ではない。既に別の App が動いていた場合と、取得して おいた更新をヘルパへ渡した場合は、どちらも 0 で終わる。戻す判断に使うのは 終了コードが 0 以外だった場合だけにした。戻す判断を誤ると、動く更新を戻した うえに取得まで捨てることになり、次の確認まで新しい版に上がれない。 Core には RollbackApplied を足した。Apply の中の巻き戻しはその場で退避した ものだけを対象にするので、「入れ替えは通ったが、その後で駄目だと分かった」 ときの戻し口が無かった。戻す前に退避がすべてそろっているかを見る。片方しか 残っていない状態で戻すと、旧 app と新 cli が混ざった一式ができる。digest も 実行ファイルの存在も通るうえ、どちらの版とも言えないものが正規の位置に居座る。 戻せなかった場合は UpdateRollbackException と同じ扱いにし、取得を捨てず、 壊れた一式を起動し直そうともしない。終了コードは、戻して開き直した場合を 10、 戻せなかった場合を既存の 7 とした。 TryRelaunchApp は起こしたプロセスも返すようにした。UseShellExecute の起動は 既に動いているものが使い回された場合に null を返しうるので、「起こせたか」と 「掴めたか」は分けてある。掴めなかった場合は生死を見る手が無いため、そのまま 任せる。 Refs #53 --- docs/update.md | 4 + src/VRCToolsDataSync.Cli/Program.cs | 185 ++++++++++++++---- .../Infra/UpdateInstaller.cs | 39 ++++ .../UpdateInstallerTests.cs | 38 ++++ 4 files changed, 230 insertions(+), 36 deletions(-) diff --git a/docs/update.md b/docs/update.md index 4f20f38..d3706da 100644 --- a/docs/update.md +++ b/docs/update.md @@ -13,6 +13,10 @@ GUI 常駐中に GitHub Releases を自動で確認し (起動 30 秒後と 1 新しい版の zip は常駐中に取得し、GitHub が公開する SHA-256 と照合してから `%AppData%\VRCToolsDataSync\update\` に置く。置き換えは次の起動時、または「再起動して適用」ボタンで行われる。実行中の一式は自分では置き換えられないため、取得した zip の中の CLI が更新ヘルパとして App の終了を待ってから `app\` と `cli\` を入れ替え、失敗した場合は元の版へ戻す。 +置き換えた一式が起動できるかどうかまでは、SHA-256 では分からない。照合が保証するのは「公開されている配布物そのものを取れたこと」までである。そこでヘルパは、入れ替えた後に起動し直した App を数秒見る。そこで落ちていれば退避しておいた一式へ戻し、取得した zip を捨てて現行版で開き直す。戻せなかった場合だけは、壊れた一式を起動し直そうとせず、`.old` を手で戻す先を伝えて終える。 + +起動直後に終わること自体は異常ではない。既に別の App が動いていた場合と、取得しておいた更新をヘルパへ渡した場合は、どちらも正常な終了として扱われる。戻す判断に使うのは、終了コードが 0 以外だった場合だけである。 + 置き換えると、直前の一式は `app.old` / `cli.old` として同じ場所に残る。次の起動がこれを消す。ウイルス対策ソフトに掴まれているなどで消せない場合は、`app.old.trash-<英数字>` のように名前をずらしてから置き換えを進め、消せるようになった時点で片付ける。**消せない残骸が置き換えを止めることはない。** 名前をずらすことすらできない場合だけ今回の置き換えを見送り、取得した zip を残したまま現行版で起動する。その場合は設定カードの「本体の更新」欄にその旨を出す。 `dotnet run` や `bin\` 配下の手元ビルドは版を持たない (`0.0.0-dev`) ため、確認も置き換えも行われない。 diff --git a/src/VRCToolsDataSync.Cli/Program.cs b/src/VRCToolsDataSync.Cli/Program.cs index 9c64e05..7fda935 100644 --- a/src/VRCToolsDataSync.Cli/Program.cs +++ b/src/VRCToolsDataSync.Cli/Program.cs @@ -837,7 +837,7 @@ void Log(Action write) // 取得をまたこちらへ渡し、こちらがまた空き不足で断念して開き直す、 // という往復になる。 singleInstance?.Dispose(); - if (relaunch) TryRelaunchApp(target, logger, relaunchMinimized, skipUpdateApply: true); + if (relaunch) TryRelaunchApp(target, logger, relaunchMinimized, skipUpdateApply: true).Process?.Dispose(); return 9; } catch (UpdateRollbackException ex) @@ -857,35 +857,8 @@ void Log(Action write) // 取得済みの更新をここで捨てる。残すと次の起動がまた同じ適用へ引き渡して // 同じ失敗を繰り返し、書き込めない場所に置かれた環境では現行版すら - // 開けなくなる。展開先はこのヘルパ自身が動いている場所なので消せないが、 - // ZIP と記録が消えれば次の起動は適用へ入らず、展開先は後始末が拾う。 - // - // 置き場所はインストール先ごとに分かれているため、対象 (--target) から - // 引く。ヘルパ自身は展開先から動いており、そこも配布 ZIP と同じ形を - // しているので、既定の置き場所を見ると自分の展開先を基にした空の場所を - // 掴む。そこを消せても、本当の ZIP と記録は残ったままになる。 - var stageDirectory = UpdateStage.DirectoryFor(target); - var applicable = true; - try - { - var stage = new UpdateStage(stageDirectory, logger); - stage.Discard(); - - // 開き直してよいのは「次の起動が同じ適用へ入らない」と言い切れる - // ときである。両方消せた場合だけでなく、片方だけ消せた場合もそう - // である。照合は対がそろっていなければ何も返さない。 - // - // 判定には記録の読み出しではなく、ファイルが残っているかを使う。 - // 読めないだけのものを「消えた」と取り違えると、次の起動がまた - // 同じ適用へ入り、開いては閉じるのを繰り返す。 - applicable = stage.StagedPairRemains(); - } - catch (Exception discard) - { - Log(() => logger.LogWarning(discard, "取得済みの更新を捨てられなかった")); - } - - if (applicable) + // 開けなくなる。 + if (!TryDiscardStaged(target, logger, out var stageDirectory)) { // 対がそろったまま残っている。ここで開き直すと、次の起動がまた同じ // 適用へ入って失敗し、開いては閉じるのを繰り返す。開き直さずに、 @@ -898,18 +871,34 @@ void Log(Action write) // 利用者から見れば、これは再起動の操作の途中である。現行版を開き直す。 singleInstance?.Dispose(); - if (relaunch) TryRelaunchApp(target, logger, relaunchMinimized); + if (relaunch) TryRelaunchApp(target, logger, relaunchMinimized).Process?.Dispose(); return 1; } singleInstance?.Dispose(); - if (relaunch && !TryRelaunchApp(target, logger, relaunchMinimized)) + if (!relaunch) return 0; + + var (started, relaunched) = TryRelaunchApp(target, logger, relaunchMinimized); + // 掴んだハンドルは、この後どの経路を通っても抜けるときに返す。 + using var relaunchedHandle = relaunched; + if (!started) { // 置き換えは済んでいる。起動し直しの失敗は利用者の手起動で補える。 return 1; } - return 0; + // 起こせたが掴めなかった。生死を見る手が無いので、そのまま任せる。 + if (relaunched is null) return 0; + + // 置き換えた一式が立ち上がったかを数秒見る (issue #53)。 + // + // digest が保証するのは「公開されている配布物そのものを取れたこと」まで + // であって、中身が起動できることではない。立ち上がらない一式が入ると、 + // 利用者は App を開けなくなる。退避した .old は残っているが、手で戻す + // 以外の経路が無い。 + if (!CrashedOnStart(relaunched, logger)) return 0; + + return RollBackFailedStart(source, target, relaunchMinimized, logger, Log); } /// @@ -921,7 +910,13 @@ void Log(Action write) /// 取得しておいたものを残したまま開き直す場合に true。開き直した App が /// 同じ取得をまたこちらへ渡してこないよう、見送りの指定を渡す。 /// -static bool TryRelaunchApp( +/// +/// 起こせたかどうかと、起こしたプロセス。UseShellExecute の起動は、 +/// 既に動いているものが使い回された場合に null を返すことがある。 +/// その場合も起動そのものは成り立っているので、掴めなかっただけとして扱う +/// (生死は見られない)。 +/// +static (bool Started, System.Diagnostics.Process? Process) TryRelaunchApp( string target, ILogger logger, bool minimized = false, bool skipUpdateApply = false) { try @@ -938,13 +933,131 @@ static bool TryRelaunchApp( if (skipUpdateApply) switches.Add(UpdateInstaller.SkipUpdateApplySwitch); if (minimized) switches.Add(StartupRegistration.MinimizedSwitch); if (switches.Count > 0) start.Arguments = string.Join(" ", switches); - System.Diagnostics.Process.Start(start); - return true; + return (true, System.Diagnostics.Process.Start(start)); } catch (Exception ex) { Console.Error.WriteLine($"App を起動し直せませんでした: {ex.Message}"); try { logger.LogWarning(ex, "App の起動し直しに失敗した"); } catch { /* best-effort */ } + return (false, null); + } +} + +/// +/// 起動し直した App が立ち上がったかを数秒見る (issue #53)。 +/// +/// 早く終わること自体は異常ではない。多重起動の抑止に当たった場合と、取得 +/// しておいた更新をヘルパへ渡した場合は、どちらも終了コード 0 で終わる。 +/// 起動できない一式 (自己完結のランタイムを欠くなど) は apphost が 0 以外で +/// 落ちるので、そこで見分ける。 +/// +/// +/// 見分けを終了コードに委ねているのは、戻す判断を誤ったときの損が大きい +/// ためである。動く更新を戻して取得まで捨てると、利用者は次の確認まで新しい +/// 版に上がれない。 +/// +/// +static bool CrashedOnStart(System.Diagnostics.Process app, ILogger logger) +{ + const int probeMilliseconds = 5000; + + try + { + if (!app.WaitForExit(probeMilliseconds)) return false; + if (app.ExitCode == 0) return false; + + Console.Error.WriteLine($"置き換えた App が起動直後に終了しました (終了コード {app.ExitCode})。"); + try + { + logger.LogError( + "置き換えた App が起動直後に終了した (終了コード {ExitCode})。退避した一式へ戻す", app.ExitCode); + } + catch { /* best-effort */ } + return true; + } + catch (Exception ex) + { + // 見られなかっただけである。置き換えは済んでいるので、そのまま任せる。 + try { logger.LogWarning(ex, "起動し直した App の生死を見られなかった"); } catch { /* best-effort */ } + return false; + } +} + +/// +/// 起動できなかった置き換えを戻し、取得を捨てて旧版を開き直す (issue #53)。 +/// +static int RollBackFailedStart( + string source, string target, bool relaunchMinimized, ILogger logger, Action log) +{ + // 戻す間は多重起動の抑止を掴む。起動した App が旧 app\ を掴むと、 + // 戻しのリネームが失敗する。落ちた App は既に手放している。 + var singleInstance = UpdateInstaller.TryHoldSingleInstance(UpdateInstaller.SingleInstanceHandOverTimeout); + + try + { + new UpdateInstaller(source, target, logger).RollbackApplied(); + Console.Error.WriteLine("退避しておいた一式へ戻しました。"); + } + catch (Exception ex) + { + // 正規の位置が欠けたままになりうる。取得しておいた ZIP は復旧の材料に + // なるため消さず、壊れた一式を起動し直そうともしない。UpdateRollbackException + // と同じ扱いにする。 + Console.Error.WriteLine($"退避しておいた一式へ戻せませんでした: {ex.Message}"); + Console.Error.WriteLine("インストール先が壊れた可能性があります。.old ディレクトリを手で戻すか、ZIP を展開し直してください。"); + log(() => logger.LogError(ex, "起動できなかった置き換えを戻せなかった")); + singleInstance?.Dispose(); + return 7; + } + + // 取得を捨てる。残すと、開き直した旧版がまた同じ一式を適用しに行き、 + // また起動できずに戻す、を繰り返す。 + if (!TryDiscardStaged(target, logger, out var stageDirectory)) + { + Console.Error.WriteLine( + $"取得済みの更新を消せませんでした。{stageDirectory} を手で削除してから起動してください。"); + log(() => logger.LogError("取得済みの更新を消せないため、App を起動し直さない")); + singleInstance?.Dispose(); + return 8; + } + + singleInstance?.Dispose(); + TryRelaunchApp(target, logger, relaunchMinimized).Process?.Dispose(); + return 10; +} + +/// +/// 取得しておいた更新を捨てる。次の起動が同じ適用へ入らないと言い切れる場合 +/// だけ true を返す。 +/// +/// 展開先はヘルパ自身が動いている場所なので消せないが、ZIP と記録が消えれば +/// 次の起動は適用へ入らず、展開先は後始末が拾う。 +/// +/// +/// 置き場所はインストール先ごとに分かれているため、対象 (--target) から +/// 引く。ヘルパ自身は展開先から動いており、そこも配布 ZIP と同じ形をして +/// いるので、既定の置き場所を見ると自分の展開先を基にした空の場所を掴む。 +/// そこを消せても、本当の ZIP と記録は残ったままになる。 +/// +/// +/// 判定には記録の読み出しではなく、ファイルが残っているかを使う。読めない +/// だけのものを「消えた」と取り違えると、次の起動がまた同じ適用へ入り、 +/// 開いては閉じるのを繰り返す。両方消せた場合だけでなく、片方だけ消せた +/// 場合も「入らない」と言える。照合は対がそろっていなければ何も返さない。 +/// +/// +static bool TryDiscardStaged(string target, ILogger logger, out string stageDirectory) +{ + stageDirectory = UpdateStage.DirectoryFor(target); + try + { + var stage = new UpdateStage(stageDirectory, logger); + stage.Discard(); + return !stage.StagedPairRemains(); + } + catch (Exception ex) + { + try { logger.LogWarning(ex, "取得済みの更新を捨てられなかった"); } catch { /* best-effort */ } return false; } } diff --git a/src/VRCToolsDataSync.Core/Infra/UpdateInstaller.cs b/src/VRCToolsDataSync.Core/Infra/UpdateInstaller.cs index ca1493e..8a94b2e 100644 --- a/src/VRCToolsDataSync.Core/Infra/UpdateInstaller.cs +++ b/src/VRCToolsDataSync.Core/Infra/UpdateInstaller.cs @@ -539,6 +539,45 @@ private void PrepareAndSwap() } } + /// + /// 済ませた入れ替えを戻す (issue #53)。 + /// + /// 置き換えた一式を起動できなかった場合に呼ぶ。 の中で + /// 使う巻き戻しは、その場で退避したものだけを対象にする。こちらは + /// 「入れ替えは通ったが、その後で駄目だと分かった」ときの戻し口である。 + /// + /// + /// 戻す前に、退避 (.old) がすべてそろっているかを見る。片方しか + /// 残っていない状態で戻すと、旧 app と新 cli が混ざった一式ができる。 + /// digest も実行ファイルの存在も通るうえ、どちらの版とも言えないものが + /// 正規の位置に居座るので、そろっていなければ何も動かさずに断る。 + /// + /// + /// 呼ぶのは、置き換えた一式のプロセスが終わっていることを確かめてからに + /// すること。動いている間は app\ 配下の DLL を掴んでおり、リネームが + /// 失敗する。 + /// + /// + /// + /// 退避がそろっていないか、戻す途中で失敗した。正規の位置が欠けたままに + /// なりうるため、取得しておいた ZIP は捨ててはならない。 + /// + public void RollbackApplied() + { + foreach (var part in Parts) + { + var backup = Path.Combine(_targetDirectory, part + ".old"); + if (Directory.Exists(backup)) continue; + + LogQuietly(() => _logger.LogError( + "退避した {Part} が無いため戻せない: {Backup}", part, backup)); + throw new UpdateRollbackException($"退避した {part} が無いため戻せない: {backup}"); + } + + RollbackSwapped(new List(Parts)); + LogQuietly(() => _logger.LogInformation("済ませた入れ替えを戻した: {Target}", _targetDirectory)); + } + /// /// (4) ランチャーを差し替える。同じディレクトリの一時ファイルへ書き切って /// から置き換える。直接上書きすると、書いている途中で容量が尽きた場合に diff --git a/tests/VRCToolsDataSync.Core.Tests/UpdateInstallerTests.cs b/tests/VRCToolsDataSync.Core.Tests/UpdateInstallerTests.cs index afb7582..af4ceab 100644 --- a/tests/VRCToolsDataSync.Core.Tests/UpdateInstallerTests.cs +++ b/tests/VRCToolsDataSync.Core.Tests/UpdateInstallerTests.cs @@ -66,6 +66,44 @@ public void SwapsBundleAndKeepsBackup() Assert.False(Directory.Exists(TargetFile("cli.old"))); } + [Fact(DisplayName = "済ませた入れ替えを戻すと、退避しておいた一式に返る")] + public void RollbackAppliedRestoresTheBackedUpBundle() + { + var installer = new UpdateInstaller(SourceDir, TargetDir); + installer.Apply(); + Assert.Equal("new-app", File.ReadAllText(TargetFile("app", "marker.txt"))); + + // 置き換えた一式が起動できなかった状況。退避から戻す。 + installer.RollbackApplied(); + + Assert.Equal("old-app", File.ReadAllText(TargetFile("app", "marker.txt"))); + Assert.Equal("old-nested", File.ReadAllText(TargetFile("app", "nested", "marker.txt"))); + Assert.Equal("old-cli", File.ReadAllText(TargetFile("cli", "marker.txt"))); + + // 戻した以上、退避は残らない。残すと次の起動の後始末が拾えなくなる。 + Assert.False(Directory.Exists(TargetFile("app.old"))); + Assert.False(Directory.Exists(TargetFile("cli.old"))); + } + + [Fact(DisplayName = "退避がそろっていなければ、何も動かさずに断る")] + public void RollbackAppliedRefusesWhenABackupIsMissing() + { + var installer = new UpdateInstaller(SourceDir, TargetDir); + installer.Apply(); + + // 片方の退避だけが消えている。ここで戻すと、旧 app と新 cli が + // 混ざった一式ができる。どちらの版とも言えないものを正規の位置へ + // 置くくらいなら、新しいまま留めるほうがましである。 + Directory.Delete(TargetFile("cli.old"), recursive: true); + + Assert.Throws(() => installer.RollbackApplied()); + + // 断った以上、app の側にも触っていない。 + Assert.Equal("new-app", File.ReadAllText(TargetFile("app", "marker.txt"))); + Assert.Equal("old-app", File.ReadAllText(TargetFile("app.old", "marker.txt"))); + Assert.Equal("new-cli", File.ReadAllText(TargetFile("cli", "marker.txt"))); + } + [Fact(DisplayName = "前回が残した .new と .old は、空きを測る前に片付ける")] public void ClearsLeftoverDirectoriesBeforeMeasuringSpace() { From 02c0c64c7462a31b464b807075eeab829d7964a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 06:34:21 +0000 Subject: [PATCH 2/5] =?UTF-8?q?fix(update):=20=E6=88=BB=E3=81=99=E7=B5=8C?= =?UTF-8?q?=E8=B7=AF=E3=81=AE=E6=8A=9C=E3=81=91=E3=82=92=E5=A1=9E=E3=81=90?= =?UTF-8?q?=20(=E3=83=A9=E3=83=B3=E3=83=81=E3=83=A3=E3=83=BC=20/=20?= =?UTF-8?q?=E8=B5=B7=E3=81=93=E3=81=9B=E3=81=AA=E3=81=84=E5=A0=B4=E5=90=88?= =?UTF-8?q?=20/=20=E6=8A=91=E6=AD=A2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex のレビューで挙がった 3 点。 ## ランチャーも戻す Apply は app と cli の入れ替えの後にルートの .cmd も新版で上書きするが、 戻すのは Parts の 2 つだけだった。ランチャーが起動する先や渡す引数は版に よって変わりうるので、旧版の一式に新版のランチャーが組み合わさる。ヘルパ からの直接の起動は通っても、次の通常の起動で食い違う。 差し替える前に .cmd.old へ退避し、戻すときに書き戻す。退避も書き戻しも 失敗は警告に留める。ここは元からそういう段であり、ランチャーは相対参照で app を起動するため版が食い違っても大抵は動く。app と cli を戻せている以上、 ここで「戻せなかった」と伝えるほうが実態から遠い。DiscardPrevious も この退避を拾うようにした。 ## 起こせなかった場合も戻す 公開された実行ファイルの形が不正だった場合や、置き換えた直後にウイルス対策 ソフトが隔離した場合は、Process.Start そのものが例外になる。置き換えたまま 残すと、利用者が手で起動しても同じ理由で失敗し、App を開く手立てが無くなる。 生死を見るまでもないので、そのまま戻す。 ## 抑止を掴めなければ戻さない TryHoldSingleInstance が null を返した結果を無視していた。別の App が動いて おり、その App は置き換えた一式で動いている。起動できない一式ではなかった ということなので、戻す理由のほうが怪しい。 掴まれたまま戻しに入ると、なお悪い。戻しは正規の位置を消してから退避を戻す ため、消す途中で失敗すると置き換えた一式まで欠ける。正規の位置には触らずに 引き下がる。 Refs #53 --- src/VRCToolsDataSync.Cli/Program.cs | 22 ++++++- .../Infra/UpdateInstaller.cs | 61 +++++++++++++++++++ .../UpdateInstallerTests.cs | 9 +++ 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/src/VRCToolsDataSync.Cli/Program.cs b/src/VRCToolsDataSync.Cli/Program.cs index 7fda935..b2c2b3c 100644 --- a/src/VRCToolsDataSync.Cli/Program.cs +++ b/src/VRCToolsDataSync.Cli/Program.cs @@ -883,8 +883,13 @@ void Log(Action write) using var relaunchedHandle = relaunched; if (!started) { - // 置き換えは済んでいる。起動し直しの失敗は利用者の手起動で補える。 - return 1; + // 起こすことすらできなかった。公開された実行ファイルの形が不正だった + // 場合や、置き換えた直後にウイルス対策ソフトが隔離した場合がここに来る。 + // 利用者が手で起動しても同じ理由で失敗するので、置き換えたまま残すと + // App を開く手立てが無くなる。生死を見るまでもないので、そのまま戻す。 + Console.Error.WriteLine("置き換えた App を起こせませんでした。退避しておいた一式へ戻します。"); + Log(() => logger.LogError("置き換えた App を起こせなかった。退避した一式へ戻す")); + return RollBackFailedStart(source, target, relaunchMinimized, logger, Log); } // 起こせたが掴めなかった。生死を見る手が無いので、そのまま任せる。 @@ -992,6 +997,19 @@ static int RollBackFailedStart( // 戻す間は多重起動の抑止を掴む。起動した App が旧 app\ を掴むと、 // 戻しのリネームが失敗する。落ちた App は既に手放している。 var singleInstance = UpdateInstaller.TryHoldSingleInstance(UpdateInstaller.SingleInstanceHandOverTimeout); + if (singleInstance is null) + { + // 待っても空かなかった。別の App が動いており、その App は置き換えた + // 一式で動いている。起動できない一式ではなかったということなので、 + // 戻す理由のほうが怪しい。 + // + // 掴まれたまま戻しに入ると、なお悪い。戻しは正規の位置を消してから + // 退避を戻すため、消す途中で失敗すると、置き換えた一式まで欠ける。 + // 正規の位置には触らずに引き下がる。 + Console.Error.WriteLine("App が起動しているため、退避しておいた一式へは戻しません。"); + log(() => logger.LogWarning("App が動いているため、起動できなかった置き換えを戻さない")); + return 1; + } try { diff --git a/src/VRCToolsDataSync.Core/Infra/UpdateInstaller.cs b/src/VRCToolsDataSync.Core/Infra/UpdateInstaller.cs index 8a94b2e..13ec2f4 100644 --- a/src/VRCToolsDataSync.Core/Infra/UpdateInstaller.cs +++ b/src/VRCToolsDataSync.Core/Infra/UpdateInstaller.cs @@ -575,9 +575,41 @@ public void RollbackApplied() } RollbackSwapped(new List(Parts)); + RestoreLauncher(); LogQuietly(() => _logger.LogInformation("済ませた入れ替えを戻した: {Target}", _targetDirectory)); } + /// + /// 退避しておいたランチャーを戻す (issue #53)。 + /// + /// 退避が無ければ何もしない。差し替えの段で退避に失敗した場合と、 + /// ランチャーの差し替えそのものが失敗した場合がそれに当たる。どちらでも + /// 正規の位置には旧版のランチャーが残っているので、戻す必要が無い。 + /// + /// + /// 戻せなかった場合も警告に留める。差し替えの段と同じ理由で、ランチャーは + /// 相対参照で app を起動するため、版が食い違っても大抵は動く。app と cli を + /// 戻せている以上、ここで例外を投げて「戻せなかった」と伝えるほうが実態から + /// 遠い。 + /// + /// + private void RestoreLauncher() + { + var launcher = Path.Combine(_targetDirectory, LauncherName); + var backup = launcher + ".old"; + if (!File.Exists(backup)) return; + + try + { + File.Move(backup, launcher, overwrite: true); + LogQuietly(() => _logger.LogInformation("退避しておいたランチャーへ戻した")); + } + catch (Exception ex) + { + LogQuietly(() => _logger.LogWarning(ex, "退避しておいたランチャーへ戻せなかった: {Name}", LauncherName)); + } + } + /// /// (4) ランチャーを差し替える。同じディレクトリの一時ファイルへ書き切って /// から置き換える。直接上書きすると、書いている途中で容量が尽きた場合に @@ -591,6 +623,20 @@ private void ReplaceLauncher() var launcherTemp = launcher + ".new"; try { + // 差し替える前に退避する。戻すときに要る (issue #53)。ランチャーが + // 起動する先や渡す引数は版によって変わりうるので、app / cli だけを + // 戻すと、旧版の一式に新版のランチャーが組み合わさる。ヘルパからの + // 直接の起動は通っても、次の通常の起動で食い違う。 + // + // 退避に失敗しても差し替えは進める。ここは元から警告に留める段で + // あり、退避が無ければ戻すときにランチャーを飛ばすだけである。 + try { File.Copy(launcher, launcher + ".old", overwrite: true); } + catch (Exception backup) + { + LogQuietly(() => _logger.LogWarning( + backup, "ランチャーを退避できなかった: {Name}", LauncherName)); + } + File.Copy(Path.Combine(_sourceDirectory, LauncherName), launcherTemp, overwrite: true); File.Move(launcherTemp, launcher, overwrite: true); } @@ -640,6 +686,21 @@ public static bool LooksComplete(string targetDirectory) public static void DiscardPrevious(string targetDirectory, ILogger? logger = null) { var log = logger ?? NullLogger.Instance; + + var launcherBackup = Path.Combine(targetDirectory, LauncherName + ".old"); + if (File.Exists(launcherBackup)) + { + try + { + File.Delete(launcherBackup); + LogQuietly(() => log.LogInformation("置き換え前のランチャーを消した")); + } + catch (Exception ex) + { + LogQuietly(() => log.LogWarning(ex, "置き換え前のランチャーを消せなかった: {Path}", launcherBackup)); + } + } + foreach (var part in Parts) { var backup = Path.Combine(targetDirectory, part + ".old"); diff --git a/tests/VRCToolsDataSync.Core.Tests/UpdateInstallerTests.cs b/tests/VRCToolsDataSync.Core.Tests/UpdateInstallerTests.cs index af4ceab..d1ffb65 100644 --- a/tests/VRCToolsDataSync.Core.Tests/UpdateInstallerTests.cs +++ b/tests/VRCToolsDataSync.Core.Tests/UpdateInstallerTests.cs @@ -56,14 +56,18 @@ public void SwapsBundleAndKeepsBackup() Assert.Equal("new-cmd", File.ReadAllText(TargetFile(UpdateInstaller.LauncherName))); // 旧一式は .old に退避され、次の起動の DiscardPrevious が消す。 + // ランチャーも同じく退避される (issue #53 で戻す材料にする)。 Assert.Equal("old-app", File.ReadAllText(TargetFile("app.old", "marker.txt"))); Assert.Equal("old-cli", File.ReadAllText(TargetFile("cli.old", "marker.txt"))); + Assert.Equal("old-cmd", File.ReadAllText(TargetFile(UpdateInstaller.LauncherName + ".old"))); Assert.False(Directory.Exists(TargetFile("app.new"))); Assert.False(Directory.Exists(TargetFile("cli.new"))); + Assert.False(File.Exists(TargetFile(UpdateInstaller.LauncherName + ".new"))); UpdateInstaller.DiscardPrevious(TargetDir); Assert.False(Directory.Exists(TargetFile("app.old"))); Assert.False(Directory.Exists(TargetFile("cli.old"))); + Assert.False(File.Exists(TargetFile(UpdateInstaller.LauncherName + ".old"))); } [Fact(DisplayName = "済ませた入れ替えを戻すと、退避しておいた一式に返る")] @@ -80,9 +84,14 @@ public void RollbackAppliedRestoresTheBackedUpBundle() Assert.Equal("old-nested", File.ReadAllText(TargetFile("app", "nested", "marker.txt"))); Assert.Equal("old-cli", File.ReadAllText(TargetFile("cli", "marker.txt"))); + // ランチャーも戻す。起動する先や渡す引数は版によって変わりうるので、 + // app / cli だけを戻すと旧版の一式に新版のランチャーが組み合わさる。 + Assert.Equal("old-cmd", File.ReadAllText(TargetFile(UpdateInstaller.LauncherName))); + // 戻した以上、退避は残らない。残すと次の起動の後始末が拾えなくなる。 Assert.False(Directory.Exists(TargetFile("app.old"))); Assert.False(Directory.Exists(TargetFile("cli.old"))); + Assert.False(File.Exists(TargetFile(UpdateInstaller.LauncherName + ".old"))); } [Fact(DisplayName = "退避がそろっていなければ、何も動かさずに断る")] From e48a592bc70313b398b1354fe0aa3f15848ca87f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 06:42:21 +0000 Subject: [PATCH 3/5] =?UTF-8?q?fix(update):=20=E9=80=80=E9=81=BF=E3=81=AE?= =?UTF-8?q?=E4=B8=AD=E8=BA=AB=E3=82=92=E7=A2=BA=E3=81=8B=E3=82=81=E3=81=A6?= =?UTF-8?q?=E3=81=8B=E3=82=89=E6=88=BB=E3=81=97=E3=80=81=E9=80=80=E9=81=BF?= =?UTF-8?q?=E3=81=A7=E3=81=8D=E3=81=AA=E3=81=91=E3=82=8C=E3=81=B0=E5=B7=AE?= =?UTF-8?q?=E3=81=97=E6=9B=BF=E3=81=88=E3=81=AA=E3=81=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex のレビュー 2 巡目。 ## 退避の中身を確かめてから戻す 戻す前の点検がディレクトリの存在だけだった。ウイルス対策ソフトや手作業で app.old の中の exe や本体 DLL だけが失われていても、そろっていると判定する。 そのまま戻すと、置き換えた一式を消して欠けた旧版を正規の位置へ移すことに なる。しかも呼び出し側はこれを「戻せた」と受け取って取得しておいた ZIP まで 捨てるので、旧版も新版も起動できず、展開し直す材料も残らない。 ValidateLayout と LooksComplete が見るのと同じ組 (実行ファイルと本体 アセンブリ) を、それぞれの退避についても見る。断って新版を留めれば、 少なくとも ZIP は残り、展開し直せる。 ## 退避できなければランチャーを差し替えない 前回の退避 (.cmd.old) が掴まれている場合、今回の退避に失敗しても差し替えを 続けていた。戻すときに前々版のランチャーを書き戻すか、退避が無ければ旧版の app / cli に新版のランチャーが残る。 前回の退避を先に消し、今回の退避に失敗した場合は差し替えないようにした。 差し替えないほうの損は小さい。ランチャーは相対参照で app を起動するので、 旧ランチャーのままでも新しい app は起動できる。この段が元から警告で済むのと 同じ理由である。 Refs #53 --- .../Infra/UpdateInstaller.cs | 87 +++++++++++++++---- .../UpdateInstallerTests.cs | 18 ++++ 2 files changed, 88 insertions(+), 17 deletions(-) diff --git a/src/VRCToolsDataSync.Core/Infra/UpdateInstaller.cs b/src/VRCToolsDataSync.Core/Infra/UpdateInstaller.cs index 13ec2f4..b795bf0 100644 --- a/src/VRCToolsDataSync.Core/Infra/UpdateInstaller.cs +++ b/src/VRCToolsDataSync.Core/Infra/UpdateInstaller.cs @@ -539,6 +539,19 @@ private void PrepareAndSwap() } } + /// + /// その part が欠けていては成り立たない実行ファイルと本体アセンブリ。 + /// + /// 配布は単一ファイルにまとめていないので、exe は起動の入り口でしかない。 + /// 隣の dll が欠けていれば起動できない。 と + /// が見るのと同じ組である。 + /// + /// + private static (string Executable, string Assembly) RequiredFilesOf(string part) + => string.Equals(part, "app", StringComparison.OrdinalIgnoreCase) + ? (AppExecutableName, AppAssemblyName) + : (CliExecutableName, CliAssemblyName); + /// /// 済ませた入れ替えを戻す (issue #53)。 /// @@ -567,11 +580,28 @@ public void RollbackApplied() foreach (var part in Parts) { var backup = Path.Combine(_targetDirectory, part + ".old"); - if (Directory.Exists(backup)) continue; + if (!Directory.Exists(backup)) + { + LogQuietly(() => _logger.LogError( + "退避した {Part} が無いため戻せない: {Backup}", part, backup)); + throw new UpdateRollbackException($"退避した {part} が無いため戻せない: {backup}"); + } + + // ディレクトリがあるだけでは足りない。中身が欠けた退避を正規の位置へ + // 置くと、戻したつもりの側も起動できない。しかも呼び出し側はこれを + // 「戻せた」と受け取って取得しておいた ZIP まで捨てるので、旧版も + // 新版も起動できず、展開し直す材料も残らない。断って新版を留めれば、 + // 少なくとも ZIP は残る。 + var (executable, assembly) = RequiredFilesOf(part); + foreach (var required in new[] { executable, assembly }) + { + var path = Path.Combine(backup, required); + if (File.Exists(path)) continue; - LogQuietly(() => _logger.LogError( - "退避した {Part} が無いため戻せない: {Backup}", part, backup)); - throw new UpdateRollbackException($"退避した {part} が無いため戻せない: {backup}"); + LogQuietly(() => _logger.LogError( + "退避した {Part} に {Required} が無いため戻せない: {Path}", part, required, path)); + throw new UpdateRollbackException($"退避した {part} に {required} が無いため戻せない: {path}"); + } } RollbackSwapped(new List(Parts)); @@ -616,27 +646,50 @@ private void RestoreLauncher() /// 欠けたランチャーが残り、通常の起動手段ごと壊れる。 /// ここで失敗しても app / cli の置き換えは成立しており、旧ランチャーは /// 無傷で、相対参照で新しい app を起動できるため、警告に留める。 + /// + /// 差し替えの前に .old へ退避する (issue #53)。退避できない場合は + /// 差し替えも行わない。差し替えたうえで戻せないと、旧版の app / cli に + /// 新版のランチャーが残る。 + /// /// private void ReplaceLauncher() { var launcher = Path.Combine(_targetDirectory, LauncherName); var launcherTemp = launcher + ".new"; + var launcherBackup = launcher + ".old"; + + // 前回の退避が残っていれば先に消す。残したまま今回の退避に失敗すると、 + // 戻すときに前々版のランチャーを書き戻すことになる。普段は次の起動の + // DiscardPrevious が消しているので、ここに残るのは消せなかった場合だけ + // である。 + try { if (File.Exists(launcherBackup)) File.Delete(launcherBackup); } + catch (Exception stale) + { + LogQuietly(() => _logger.LogWarning( + stale, "前回のランチャーの退避を消せなかった: {Path}", launcherBackup)); + } + + // 差し替える前に退避する。戻すときに要る (issue #53)。ランチャーが + // 起動する先や渡す引数は版によって変わりうるので、app / cli だけを + // 戻すと、旧版の一式に新版のランチャーが組み合わさる。ヘルパからの + // 直接の起動は通っても、次の通常の起動で食い違う。 try { - // 差し替える前に退避する。戻すときに要る (issue #53)。ランチャーが - // 起動する先や渡す引数は版によって変わりうるので、app / cli だけを - // 戻すと、旧版の一式に新版のランチャーが組み合わさる。ヘルパからの - // 直接の起動は通っても、次の通常の起動で食い違う。 - // - // 退避に失敗しても差し替えは進める。ここは元から警告に留める段で - // あり、退避が無ければ戻すときにランチャーを飛ばすだけである。 - try { File.Copy(launcher, launcher + ".old", overwrite: true); } - catch (Exception backup) - { - LogQuietly(() => _logger.LogWarning( - backup, "ランチャーを退避できなかった: {Name}", LauncherName)); - } + File.Copy(launcher, launcherBackup, overwrite: true); + } + catch (Exception backup) + { + // 退避できないなら差し替えない。差し替えたうえで戻せないと、旧版の + // app / cli に新版のランチャーが残る。差し替えないほうの損は小さい。 + // ランチャーは相対参照で app を起動するので、旧ランチャーのままでも + // 新しい app は起動できる (この段が元から警告で済むのと同じ理由)。 + LogQuietly(() => _logger.LogWarning( + backup, "ランチャーを退避できないため差し替えない: {Name}", LauncherName)); + return; + } + try + { File.Copy(Path.Combine(_sourceDirectory, LauncherName), launcherTemp, overwrite: true); File.Move(launcherTemp, launcher, overwrite: true); } diff --git a/tests/VRCToolsDataSync.Core.Tests/UpdateInstallerTests.cs b/tests/VRCToolsDataSync.Core.Tests/UpdateInstallerTests.cs index d1ffb65..2d7d4f4 100644 --- a/tests/VRCToolsDataSync.Core.Tests/UpdateInstallerTests.cs +++ b/tests/VRCToolsDataSync.Core.Tests/UpdateInstallerTests.cs @@ -94,6 +94,24 @@ public void RollbackAppliedRestoresTheBackedUpBundle() Assert.False(File.Exists(TargetFile(UpdateInstaller.LauncherName + ".old"))); } + [Fact(DisplayName = "退避の中身が欠けていれば、何も動かさずに断る")] + public void RollbackAppliedRefusesWhenABackupIsIncomplete() + { + var installer = new UpdateInstaller(SourceDir, TargetDir); + installer.Apply(); + + // ディレクトリはあるが、本体アセンブリだけ失われている。ここで戻すと、 + // 戻したつもりの側も起動できない。しかも呼び出し側はこれを「戻せた」と + // 受け取って取得しておいた ZIP まで捨てるので、展開し直す材料も残らない。 + File.Delete(TargetFile("app.old", UpdateInstaller.AppAssemblyName)); + + Assert.Throws(() => installer.RollbackApplied()); + + // 断った以上、正規の位置には触っていない。ZIP を展開し直せる状態が残る。 + Assert.Equal("new-app", File.ReadAllText(TargetFile("app", "marker.txt"))); + Assert.Equal("new-cli", File.ReadAllText(TargetFile("cli", "marker.txt"))); + } + [Fact(DisplayName = "退避がそろっていなければ、何も動かさずに断る")] public void RollbackAppliedRefusesWhenABackupIsMissing() { From 2b15d1e72b126fa4312347e5d7befd1aa12bd76d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 06:46:20 +0000 Subject: [PATCH 4/5] =?UTF-8?q?fix(update):=20=E8=B5=B7=E5=8B=95=E3=82=92?= =?UTF-8?q?=E8=A6=8B=E3=82=8B=E9=96=93=E3=81=AF=E9=81=A9=E7=94=A8=E3=81=AE?= =?UTF-8?q?=E3=83=AD=E3=83=83=E3=82=AF=E3=82=92=E8=BF=94=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 観察の 5 秒間、ヘルパは適用の mutex を握ったままだった。起こした App は起動の 先頭 (取得しておいた更新の照合) でこのロックを待って止まる。観察はその止まって いる姿を「生きている」と読み、ヘルパが終わってロックが空いた後に WinUI の 初期化が落ちても、誰も戻さない。 捕まえられるのは、App が照合へ到達する前に落ちる場合だけになっていた。apphost がランタイムを読み込めない場合はそこで落ちるので見えるが、Windows App SDK の DLL を欠くような、managed のコードが動き出した後の失敗は見えない。issue #53 が 挙げている「起動には他にも hostfxr.dll などのランタイムが要る」の後半が抜ける。 起こす前にロックを返し、戻すときに取り直すようにした。返しても二重適用には ならない。起こした App の照合は、インストール先に入っている版と突き合わせて 素通りする (#52)。置き換えは済んでいるので、入っている版は取得しておいた版と 同じである。 取り直せない場合は正規の位置に触らない。返していた間に別のプロセスが適用へ 入っている可能性があり、相手の置き換えと重なると、どちらの版とも言えないものが 残る。上限は短くてよい。起こした App は照合の間しか握らず、それは既に終わって いる (終わったから観察が落ちたと読んだ)。 適用の mutex は ApplyLockHolder で持つようにした。返すのと取り直すのを何度でも 呼べるようにするためで、掴み方と上限はこれまでと同じである。 Refs #53 --- src/VRCToolsDataSync.Cli/Program.cs | 122 +++++++++++++++++++++++----- 1 file changed, 101 insertions(+), 21 deletions(-) diff --git a/src/VRCToolsDataSync.Cli/Program.cs b/src/VRCToolsDataSync.Cli/Program.cs index b2c2b3c..837151e 100644 --- a/src/VRCToolsDataSync.Cli/Program.cs +++ b/src/VRCToolsDataSync.Cli/Program.cs @@ -662,21 +662,12 @@ static int ApplySelfUpdate( // // 上限は呼び出し元の終了待ちに合わせて広く取る。呼び出し元は終了時に // 同期を流すことがあり、その間はロックを握ったままである。 - using var applyMutex = UpdateStage.CreateApplyMutex(target); - var applyMutexHeld = false; - try - { - // 上限は WaitForCaller と同じ長さにする。呼び出し元が終了時 Push を - // 流している間、ロックはそちらが握ったままである。 - applyMutexHeld = applyMutex.WaitOne(TimeSpan.FromMinutes(65)); - } - catch (AbandonedMutexException) - { - // 呼び出し元が握ったまま終わった (想定の経路)、または前のヘルパが - // 握ったまま落ちた。どちらも所有権はこちらに渡っている。 - applyMutexHeld = true; - } - if (!applyMutexHeld) + // + // 握りっぱなしにはしない。置き換えた App が立ち上がったかを見る間は返す + // (issue #53)。握ったままだと、起こした App が起動の先頭でこれを待って + // 止まり、その姿を「生きている」と読んでしまう。 + using var applyLock = new ApplyLockHolder(UpdateStage.CreateApplyMutex(target)); + if (!applyLock.TryAcquire(TimeSpan.FromMinutes(65))) { // 別のヘルパが動いている。二重に適用しない。 Console.Error.WriteLine("別の更新処理が実行中のため、置き換えを中止しました。"); @@ -717,11 +708,12 @@ static int ApplySelfUpdate( return 6; } - return ApplySelfUpdateCore(source, target, relaunch, relaunchMinimized, logger, singleInstance); + return ApplySelfUpdateCore( + source, target, relaunch, relaunchMinimized, logger, singleInstance, applyLock); } finally { - try { applyMutex.ReleaseMutex(); } catch { /* best-effort */ } + applyLock.Release(); } } @@ -805,7 +797,8 @@ static int ApplySelfUpdateCore( bool relaunch, bool relaunchMinimized, ILogger logger, - IDisposable? singleInstance = null) + IDisposable? singleInstance = null, + ApplyLockHolder? applyLock = null) { // ヘルパの流れの上にあるログは、失敗しても流れを止めない。 // ログの出力先 (%AppData%) が書き込み不可だったり容量が尽きていたりすると、 @@ -875,6 +868,16 @@ void Log(Action write) return 1; } + // 起こす前に適用のロックを返す (issue #53)。握ったままだと、起こした App は + // 起動の先頭 (取得しておいた更新の照合) でこれを待って止まる。観察はその + // 止まっている姿を「生きている」と読むので、ロックが空いた後の初期化の + // 失敗を捕まえられない。 + // + // 返しても二重適用にはならない。起こした App の照合は、インストール先に + // 入っている版と突き合わせて素通りする (#52)。置き換えは済んでいるので、 + // 入っている版は取得しておいた版と同じである。 + applyLock?.Release(); + singleInstance?.Dispose(); if (!relaunch) return 0; @@ -889,7 +892,7 @@ void Log(Action write) // App を開く手立てが無くなる。生死を見るまでもないので、そのまま戻す。 Console.Error.WriteLine("置き換えた App を起こせませんでした。退避しておいた一式へ戻します。"); Log(() => logger.LogError("置き換えた App を起こせなかった。退避した一式へ戻す")); - return RollBackFailedStart(source, target, relaunchMinimized, logger, Log); + return RollBackFailedStart(source, target, relaunchMinimized, logger, Log, applyLock); } // 起こせたが掴めなかった。生死を見る手が無いので、そのまま任せる。 @@ -903,7 +906,7 @@ void Log(Action write) // 以外の経路が無い。 if (!CrashedOnStart(relaunched, logger)) return 0; - return RollBackFailedStart(source, target, relaunchMinimized, logger, Log); + return RollBackFailedStart(source, target, relaunchMinimized, logger, Log, applyLock); } /// @@ -992,8 +995,26 @@ static bool CrashedOnStart(System.Diagnostics.Process app, ILogger logger) /// 起動できなかった置き換えを戻し、取得を捨てて旧版を開き直す (issue #53)。 /// static int RollBackFailedStart( - string source, string target, bool relaunchMinimized, ILogger logger, Action log) + string source, + string target, + bool relaunchMinimized, + ILogger logger, + Action log, + ApplyLockHolder? applyLock) { + // 戻す間は適用のロックを取り直す。観察のために返していた間に、別の + // プロセスが適用へ入っている可能性がある。取れなければ正規の位置には + // 触らない。相手の置き換えと重なると、どちらの版とも言えないものが残る。 + // + // 上限は短くてよい。起こした App は照合の間しか握らず、それは既に + // 終わっている (終わったから観察が落ちたと読んだ)。 + if (applyLock is not null && !applyLock.TryAcquire(TimeSpan.FromMinutes(1))) + { + Console.Error.WriteLine("別の更新処理が実行中のため、退避しておいた一式へは戻しません。"); + log(() => logger.LogWarning("適用のロックを取り直せないため、起動できなかった置き換えを戻さない")); + return 1; + } + // 戻す間は多重起動の抑止を掴む。起動した App が旧 app\ を掴むと、 // 戻しのリネームが失敗する。落ちた App は既に手放している。 var singleInstance = UpdateInstaller.TryHoldSingleInstance(UpdateInstaller.SingleInstanceHandOverTimeout); @@ -1119,6 +1140,65 @@ static string Mask(string value) return value.Length <= 4 ? new string('*', value.Length) : value[..4] + new string('*', value.Length - 4); } +/// +/// 適用のロックを、途中で返して取り直せる形で持つ (issue #53)。 +/// +/// 置き換えた App が立ち上がったかを見る間は、こちらがロックを握っていては +/// ならない。握ったままだと、起こした App は起動の先頭 (取得しておいた更新の +/// 照合) でこのロックを待って止まる。観察はその止まっている姿を「生きている」 +/// と読み、ロックが空いた後で初期化に失敗しても誰も戻さない。 +/// +/// +/// の所有権はスレッドに紐づく。ヘルパは最初から最後まで +/// 同じスレッドで動くので、取得も解放もそのまま行える。 +/// +/// +internal sealed class ApplyLockHolder : IDisposable +{ + private readonly Mutex _mutex; + private bool _held; + + public ApplyLockHolder(Mutex mutex) => _mutex = mutex; + + /// 掴む。既に掴んでいれば何もしない。 + public bool TryAcquire(TimeSpan timeout) + { + if (_held) return true; + + try + { + _held = _mutex.WaitOne(timeout); + } + catch (AbandonedMutexException) + { + // 呼び出し元が握ったまま終わった (想定の経路)、または前のヘルパが + // 握ったまま落ちた。どちらも所有権はこちらに渡っている。 + _held = true; + } + catch (Exception) + { + _held = false; + } + + return _held; + } + + /// 返す。既に返していれば何もしない。 + public void Release() + { + if (!_held) return; + + _held = false; + try { _mutex.ReleaseMutex(); } catch { /* best-effort */ } + } + + public void Dispose() + { + Release(); + _mutex.Dispose(); + } +} + internal static class CliConstants { /// シークレットアクセスキーを渡せる環境変数の名前。 From b579e362d29eee8a1b0909e287e92a69b613f16c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 06:54:05 +0000 Subject: [PATCH 5/5] =?UTF-8?q?fix(update):=20=E5=BE=8C=E5=A7=8B=E6=9C=AB?= =?UTF-8?q?=E3=81=8C=E9=80=80=E9=81=BF=E3=82=92=E7=89=87=E4=BB=98=E3=81=91?= =?UTF-8?q?=E3=81=9F=E5=BE=8C=E3=81=AE=E7=95=B0=E5=B8=B8=E7=B5=82=E4=BA=86?= =?UTF-8?q?=E3=82=92=E3=80=81=E6=88=BB=E3=81=99=E7=9B=B8=E6=89=8B=E3=81=8B?= =?UTF-8?q?=E3=82=89=E5=A4=96=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 適用のロックを観察の間だけ返すようにしたことで、起こした App が CleanUpAfterSuccessfulStart まで進めるようになった。後始末は DiscardPrevious で退避 (.old) と取得を消すので、その後で App が落ちると、戻そうとしても退避が 無く、RollbackApplied が「戻せない」と投げる。利用者には「インストール先が 壊れた可能性があります」と出るが、実態は違う。 後始末はウィンドウを立てられた後にしか走らない。退避が消えているということは、 置き換えた一式はそこまで動いたということである。起動は成り立っており、その後で 落ちた。戻す相手が違う。 戻しへ入る前に退避が残っているかを見て、無ければ正規の位置に触らずに引き下がる ようにした。判定は適用のロックを取り直した後に行う。後始末も同じロックの下で 走るので、その時点で走り終えているか、まだ始まっていないかのどちらかになる。 あわせて docs の記述を直した。見ているのはプロセスが落ちたかどうかであり、 落ちずに止まる形の失敗は捕まらない。 Refs #53 --- docs/update.md | 2 ++ src/VRCToolsDataSync.Cli/Program.cs | 15 +++++++++++++++ .../Infra/UpdateInstaller.cs | 19 +++++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/docs/update.md b/docs/update.md index d3706da..033cf63 100644 --- a/docs/update.md +++ b/docs/update.md @@ -17,6 +17,8 @@ GUI 常駐中に GitHub Releases を自動で確認し (起動 30 秒後と 1 起動直後に終わること自体は異常ではない。既に別の App が動いていた場合と、取得しておいた更新をヘルパへ渡した場合は、どちらも正常な終了として扱われる。戻す判断に使うのは、終了コードが 0 以外だった場合だけである。 +見ているのはプロセスが落ちたかどうかである。落ちずに止まる形の失敗 (ウィンドウを作れないまま常駐してしまう等) はここでは捕まらない。 + 置き換えると、直前の一式は `app.old` / `cli.old` として同じ場所に残る。次の起動がこれを消す。ウイルス対策ソフトに掴まれているなどで消せない場合は、`app.old.trash-<英数字>` のように名前をずらしてから置き換えを進め、消せるようになった時点で片付ける。**消せない残骸が置き換えを止めることはない。** 名前をずらすことすらできない場合だけ今回の置き換えを見送り、取得した zip を残したまま現行版で起動する。その場合は設定カードの「本体の更新」欄にその旨を出す。 `dotnet run` や `bin\` 配下の手元ビルドは版を持たない (`0.0.0-dev`) ため、確認も置き換えも行われない。 diff --git a/src/VRCToolsDataSync.Cli/Program.cs b/src/VRCToolsDataSync.Cli/Program.cs index 837151e..8f7c001 100644 --- a/src/VRCToolsDataSync.Cli/Program.cs +++ b/src/VRCToolsDataSync.Cli/Program.cs @@ -1015,6 +1015,21 @@ static int RollBackFailedStart( return 1; } + // 退避が残っているかを、ロックを取ってから見る。後始末 (DiscardPrevious) + // は適用のロックの下で走るので、ここまで来た時点で走り終えているか、まだ + // 始まっていないかのどちらかである。 + // + // 残っていなければ、置き換えた一式は後始末が走るところまで動いたという + // ことである。後始末はウィンドウを立てられた後にしか走らない。つまり + // 起動は成り立っており、その後で落ちた。戻す相手が違う。 + if (!UpdateInstaller.HasBackups(target)) + { + Console.Error.WriteLine("置き換えた一式は起動できていました。退避しておいた一式へは戻しません。"); + log(() => logger.LogWarning( + "後始末が退避を片付けた後に落ちたため、起動できなかった置き換えとしては扱わない")); + return 1; + } + // 戻す間は多重起動の抑止を掴む。起動した App が旧 app\ を掴むと、 // 戻しのリネームが失敗する。落ちた App は既に手放している。 var singleInstance = UpdateInstaller.TryHoldSingleInstance(UpdateInstaller.SingleInstanceHandOverTimeout); diff --git a/src/VRCToolsDataSync.Core/Infra/UpdateInstaller.cs b/src/VRCToolsDataSync.Core/Infra/UpdateInstaller.cs index b795bf0..601edb6 100644 --- a/src/VRCToolsDataSync.Core/Infra/UpdateInstaller.cs +++ b/src/VRCToolsDataSync.Core/Infra/UpdateInstaller.cs @@ -731,6 +731,25 @@ public static bool LooksComplete(string targetDirectory) return true; } + /// + /// 退避 (.old) が のぶんだけ残っているか (issue #53)。 + /// + /// 戻せるかどうかの目安であると同時に、「起動が成り立ったか」の手掛かりでも + /// ある。退避を消すのは後始末 () だけで、後始末は + /// ウィンドウを立てられた後にしか走らない。そろっていなければ、置き換えた + /// 一式はそこまで動いたということである。 + /// + /// + public static bool HasBackups(string targetDirectory) + { + foreach (var part in Parts) + { + if (!Directory.Exists(Path.Combine(targetDirectory, part + ".old"))) return false; + } + + return true; + } + /// /// 置き換えの後始末。次の起動 (新しい版) から呼び、退避した .old と、 /// 消せずに名前をずらした残骸を消す。