diff --git a/docs/update.md b/docs/update.md index 4f20f38..033cf63 100644 --- a/docs/update.md +++ b/docs/update.md @@ -13,6 +13,12 @@ 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..8f7c001 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%) が書き込み不可だったり容量が尽きていたりすると、 @@ -837,7 +830,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 +850,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 +864,49 @@ void Log(Action write) // 利用者から見れば、これは再起動の操作の途中である。現行版を開き直す。 singleInstance?.Dispose(); - if (relaunch) TryRelaunchApp(target, logger, relaunchMinimized); + if (relaunch) TryRelaunchApp(target, logger, relaunchMinimized).Process?.Dispose(); return 1; } + // 起こす前に適用のロックを返す (issue #53)。握ったままだと、起こした App は + // 起動の先頭 (取得しておいた更新の照合) でこれを待って止まる。観察はその + // 止まっている姿を「生きている」と読むので、ロックが空いた後の初期化の + // 失敗を捕まえられない。 + // + // 返しても二重適用にはならない。起こした App の照合は、インストール先に + // 入っている版と突き合わせて素通りする (#52)。置き換えは済んでいるので、 + // 入っている版は取得しておいた版と同じである。 + applyLock?.Release(); + 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; + // 起こすことすらできなかった。公開された実行ファイルの形が不正だった + // 場合や、置き換えた直後にウイルス対策ソフトが隔離した場合がここに来る。 + // 利用者が手で起動しても同じ理由で失敗するので、置き換えたまま残すと + // App を開く手立てが無くなる。生死を見るまでもないので、そのまま戻す。 + Console.Error.WriteLine("置き換えた App を起こせませんでした。退避しておいた一式へ戻します。"); + Log(() => logger.LogError("置き換えた App を起こせなかった。退避した一式へ戻す")); + return RollBackFailedStart(source, target, relaunchMinimized, logger, Log, applyLock); } - 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, applyLock); } /// @@ -921,7 +918,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 +941,177 @@ 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, + ApplyLockHolder? applyLock) +{ + // 戻す間は適用のロックを取り直す。観察のために返していた間に、別の + // プロセスが適用へ入っている可能性がある。取れなければ正規の位置には + // 触らない。相手の置き換えと重なると、どちらの版とも言えないものが残る。 + // + // 上限は短くてよい。起こした App は照合の間しか握らず、それは既に + // 終わっている (終わったから観察が落ちたと読んだ)。 + if (applyLock is not null && !applyLock.TryAcquire(TimeSpan.FromMinutes(1))) + { + Console.Error.WriteLine("別の更新処理が実行中のため、退避しておいた一式へは戻しません。"); + log(() => logger.LogWarning("適用のロックを取り直せないため、起動できなかった置き換えを戻さない")); + return 1; + } + + // 退避が残っているかを、ロックを取ってから見る。後始末 (DiscardPrevious) + // は適用のロックの下で走るので、ここまで来た時点で走り終えているか、まだ + // 始まっていないかのどちらかである。 + // + // 残っていなければ、置き換えた一式は後始末が走るところまで動いたという + // ことである。後始末はウィンドウを立てられた後にしか走らない。つまり + // 起動は成り立っており、その後で落ちた。戻す相手が違う。 + if (!UpdateInstaller.HasBackups(target)) + { + Console.Error.WriteLine("置き換えた一式は起動できていました。退避しておいた一式へは戻しません。"); + log(() => logger.LogWarning( + "後始末が退避を片付けた後に落ちたため、起動できなかった置き換えとしては扱わない")); + return 1; + } + + // 戻す間は多重起動の抑止を掴む。起動した 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 + { + 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; } } @@ -988,6 +1155,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 { /// シークレットアクセスキーを渡せる環境変数の名前。 diff --git a/src/VRCToolsDataSync.Core/Infra/UpdateInstaller.cs b/src/VRCToolsDataSync.Core/Infra/UpdateInstaller.cs index ca1493e..601edb6 100644 --- a/src/VRCToolsDataSync.Core/Infra/UpdateInstaller.cs +++ b/src/VRCToolsDataSync.Core/Infra/UpdateInstaller.cs @@ -539,17 +539,155 @@ 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)。 + /// + /// 置き換えた一式を起動できなかった場合に呼ぶ。 の中で + /// 使う巻き戻しは、その場で退避したものだけを対象にする。こちらは + /// 「入れ替えは通ったが、その後で駄目だと分かった」ときの戻し口である。 + /// + /// + /// 戻す前に、退避 (.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)) + { + 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} に {Required} が無いため戻せない: {Path}", part, required, path)); + throw new UpdateRollbackException($"退避した {part} に {required} が無いため戻せない: {path}"); + } + } + + 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) ランチャーを差し替える。同じディレクトリの一時ファイルへ書き切って /// から置き換える。直接上書きすると、書いている途中で容量が尽きた場合に /// 欠けたランチャーが残り、通常の起動手段ごと壊れる。 /// ここで失敗しても 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 + { + 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); @@ -593,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 と、 /// 消せずに名前をずらした残骸を消す。 @@ -601,6 +758,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 afb7582..2d7d4f4 100644 --- a/tests/VRCToolsDataSync.Core.Tests/UpdateInstallerTests.cs +++ b/tests/VRCToolsDataSync.Core.Tests/UpdateInstallerTests.cs @@ -56,14 +56,79 @@ 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 = "済ませた入れ替えを戻すと、退避しておいた一式に返る")] + 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"))); + + // ランチャーも戻す。起動する先や渡す引数は版によって変わりうるので、 + // 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 = "退避の中身が欠けていれば、何も動かさずに断る")] + 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() + { + 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 は、空きを測る前に片付ける")]