diff --git a/docs/update.md b/docs/update.md index c9b7f7f..9aa6bb0 100644 --- a/docs/update.md +++ b/docs/update.md @@ -15,6 +15,8 @@ GUI 常駐中に GitHub Releases を自動で確認し (起動 30 秒後と 1 置き換えた一式が起動できるかどうかまでは、SHA-256 では分からない。照合が保証するのは「公開されている配布物そのものを取れたこと」までである。そこでヘルパは、入れ替えた後に起動し直した App を数秒見る。そこで落ちていれば退避しておいた一式へ戻し、取得した zip を捨てて現行版で開き直す。戻せなかった場合だけは、壊れた一式を起動し直そうとせず、`.old` を手で戻す先を伝えて終える。 +戻したときは、その配布物を覚えておく。覚えないと、開き直した App が次の確認で同じものをまた取得し、次の起動でまた戻すことになる。起動のたびに数十〜百数十 MB を空振りする。覚えるのはタグと SHA-256 の組なので、**同じタグへ直した一式が上げ直されれば取りに行く。** その間、設定カードの「本体の更新」欄には取得しない旨を出す。 + 起動直後に終わること自体は異常ではない。既に別の App が動いていた場合と、取得しておいた更新をヘルパへ渡した場合は、どちらも正常な終了として扱われる。戻す判断に使うのは、終了コードが 0 以外だった場合だけである。 見ているのはプロセスが落ちたかどうかである。起動の途中で例外が出た場合はアプリごと落ちるので (下記)、ここで拾える。ウィンドウを出した後の描画で失敗するような、起動を抜けてから起こる形は拾えない。 diff --git a/src/VRCToolsDataSync.App/Services/UpdateManager.cs b/src/VRCToolsDataSync.App/Services/UpdateManager.cs index 69eb777..6f7ffb1 100644 --- a/src/VRCToolsDataSync.App/Services/UpdateManager.cs +++ b/src/VRCToolsDataSync.App/Services/UpdateManager.cs @@ -186,6 +186,16 @@ public void MarkNotified(ReleaseInfo release) /// 取得済みで置き換え待ちの記録。照合はせず、表示にだけ使う。 public StagedMetadata? Staged => _stage.TryLoadMetadata(); + /// + /// この版は、一度置き換えて起動できなかったものか (issue #76)。 + /// + /// 画面に出す文言を分けるために使う。取得しないことを伝えないと、 + /// 「新しい版が出ています」と出たまま、いつまでも取得済みにならない。 + /// + /// + public bool IsKnownStartFailure(ReleaseInfo release) + => _stage.IsKnownStartFailure(release.Tag, release.Asset?.DigestHex); + /// /// 置き換えまわりの後始末を行い、済んだことを画面へ伝える (issue #45 第 3 段階)。 /// @@ -376,6 +386,19 @@ private async Task RunDownloadAsync(ReleaseInfo release, UpdateChannel channel) if (!MatchesSavedChannel(channel)) return; if (!release.IsInChannel(channel)) return; + // 一度置き換えて起動できなかった配布物は取りに行かない (issue #76)。 + // 取りに行くと、次の起動でまた適用して、また戻すことになる。起動の + // たびに数十〜百数十 MB を空振りする。 + // + // 同じタグへ直した一式が上げ直された場合は digest が変わるので、 + // そちらは覚えた記録に当たらず取りに行ける。 + if (_stage.IsKnownStartFailure(release.Tag, asset.DigestHex)) + { + _logger.LogInformation( + "{Tag} は置き換えても起動できなかったため取得しない", release.Tag); + return; + } + // タグだけでなく digest と大きさも見る。同じタグへ配布物を上げ直す // 運用があり (release.yml の --clobber)、タグだけで済ませると // 差し替え前のものを適用し続ける。stable の印も見る。プレリリース diff --git a/src/VRCToolsDataSync.App/ViewModels/MainPageViewModel.cs b/src/VRCToolsDataSync.App/ViewModels/MainPageViewModel.cs index d5a15fe..a31d05f 100644 --- a/src/VRCToolsDataSync.App/ViewModels/MainPageViewModel.cs +++ b/src/VRCToolsDataSync.App/ViewModels/MainPageViewModel.cs @@ -735,8 +735,15 @@ private void HandleUpdateCheckCompleted(UpdateCheckResult result, UpdateChannel var current = _updates.CurrentVersion; + // 一度置き換えて起動できなかった版は取りに行かない (issue #76)。 + // そのまま「出ています」とだけ出すと、いつまでも取得済みにならない + // 理由が分からない。 + var startFailed = result.Release is { } found && _updates.IsKnownStartFailure(found); + UpdateStatus = result.Outcome switch { + UpdateCheckOutcome.Available when startFailed => + $"新しい版 {result.Release!.Tag} が出ていますが、この PC では起動できなかったため取得しません", UpdateCheckOutcome.Available => $"新しい版 {result.Release!.Tag} が出ています (実行中: {current})", UpdateCheckOutcome.UpToDate when result.Release is not null => @@ -756,14 +763,18 @@ private void HandleUpdateCheckCompleted(UpdateCheckResult result, UpdateChannel if (result.Outcome == UpdateCheckOutcome.Available && result.Release is { } release) { - AppendLog($"新しい版 {release.Tag} が出ています (実行中: {current})"); + AppendLog(startFailed + ? $"新しい版 {release.Tag} が出ていますが、この PC では起動できなかったため取得しません" + : $"新しい版 {release.Tag} が出ています (実行中: {current})"); if (!manual) { // 自動の確認は背後で走るので、画面を見ていない利用者にも届くよう // バルーンを出す。押した人は画面の前に居るので出さない。 ToastRequested?.Invoke( "VRCToolsDataSync の更新", - $"新しい版 {release.Tag} が出ています。ウィンドウの設定から開けます。"); + startFailed + ? $"新しい版 {release.Tag} は、置き換えても起動できなかったため取得しません。" + : $"新しい版 {release.Tag} が出ています。ウィンドウの設定から開けます。"); } // 画面と通知に出せた後で覚える。出せなかった版まで覚えると、 // 利用者が一度も見ないまま以後の確認で抑止される。 diff --git a/src/VRCToolsDataSync.Cli/Program.cs b/src/VRCToolsDataSync.Cli/Program.cs index 8f7c001..858b0ab 100644 --- a/src/VRCToolsDataSync.Cli/Program.cs +++ b/src/VRCToolsDataSync.Cli/Program.cs @@ -1064,6 +1064,12 @@ static int RollBackFailedStart( return 7; } + // 何を捨てるのかを、捨てる前に控えておく (issue #76)。捨てた後では読めない。 + var stage = new UpdateStage(UpdateStage.DirectoryFor(target), logger); + StagedMetadata? failed = null; + try { failed = stage.TryLoadMetadata(); } + catch (Exception ex) { log(() => logger.LogWarning(ex, "起動できなかった配布物の記録を読めなかった")); } + // 取得を捨てる。残すと、開き直した旧版がまた同じ一式を適用しに行き、 // また起動できずに戻す、を繰り返す。 if (!TryDiscardStaged(target, logger, out var stageDirectory)) @@ -1075,6 +1081,15 @@ static int RollBackFailedStart( return 8; } + // 起動できなかった配布物を覚える (issue #76)。捨てただけでは、開き直した + // 旧版が次の確認で同じものをまた取得し、次の起動でまた適用しに行く。 + // 起動のたびに数十〜百数十 MB を空振りすることになる。 + if (failed is not null) + { + try { stage.RecordStartFailure(failed.Tag, failed.DigestHex); } + catch (Exception ex) { log(() => logger.LogWarning(ex, "起動できなかった配布物を覚えられなかった")); } + } + singleInstance?.Dispose(); TryRelaunchApp(target, logger, relaunchMinimized).Process?.Dispose(); return 10; diff --git a/src/VRCToolsDataSync.Core/Infra/UpdateStage.cs b/src/VRCToolsDataSync.Core/Infra/UpdateStage.cs index f1395a9..627ca2f 100644 --- a/src/VRCToolsDataSync.Core/Infra/UpdateStage.cs +++ b/src/VRCToolsDataSync.Core/Infra/UpdateStage.cs @@ -826,6 +826,68 @@ private Exception ClassifyExtractFailure(Exception ex) $"{failures} 回続けて展開できなかったため配布物の問題として扱う: {ZipPath}", ex); } + /// + /// 起動できなかった配布物の置き場所 (issue #76)。 + /// + /// 記録 () とは別のファイルに置く。取得を捨てても + /// 残す必要があるためで、 はこれに触らない。 + /// + /// + private string StartFailurePath => Path.Combine(Directory, "start-failure"); + + /// + /// 起動できなかった配布物を覚える (issue #76)。 + /// + /// 置き換えた一式が起動できず、退避へ戻した後に呼ぶ。覚えないと、旧版で + /// 開き直した App が次の確認で同じ配布物をまた取得し、次の起動でまた適用 + /// しに行く。起動のたびに数十〜百数十 MB を空振りすることになる。 + /// + /// + /// タグと digest の組で覚える。同じタグへ配布物を上げ直す運用がある + /// (release.yml の --clobber)。直した一式が同じタグで公開された + /// 場合は digest が変わるので、そちらは覚えた記録に当たらず取りに行ける。 + /// + /// + public void RecordStartFailure(string tag, string digestHex) + { + try + { + System.IO.Directory.CreateDirectory(Directory); + File.WriteAllText(StartFailurePath, $"{digestHex} {tag}"); + _logger.LogInformation("起動できなかった配布物として覚える: {Tag}", tag); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // 覚えられなくても、失うのは次の確認 1 回ぶんの取得である。 + _logger.LogWarning(ex, "起動できなかった配布物を覚えられなかった: {Path}", StartFailurePath); + } + } + + /// + /// 覚えている「起動できなかった配布物」と同じか (issue #76)。 + /// + /// 読めない場合は false を返す。読めないことを理由に更新を止めるほうが損が + /// 大きい。空振りは 1 回で済むが、止めると新しい版が入らなくなる。 + /// + /// + public bool IsKnownStartFailure(string tag, string? digestHex) + { + if (string.IsNullOrEmpty(digestHex)) return false; + + try + { + var parts = File.ReadAllText(StartFailurePath).Split(' ', 2); + return parts.Length == 2 + && string.Equals(parts[0], digestHex, StringComparison.Ordinal) + && string.Equals(parts[1], tag, StringComparison.Ordinal); + } + catch (Exception) + { + // 覚えていない (ファイルが無い) 場合もここへ来る。 + return false; + } + } + /// 同じ ZIP の展開をあきらめるまでの回数。 private const int MaxExtractAttempts = 3; diff --git a/tests/VRCToolsDataSync.Core.Tests/UpdateStageTests.cs b/tests/VRCToolsDataSync.Core.Tests/UpdateStageTests.cs index f7959a0..a70d054 100644 --- a/tests/VRCToolsDataSync.Core.Tests/UpdateStageTests.cs +++ b/tests/VRCToolsDataSync.Core.Tests/UpdateStageTests.cs @@ -372,6 +372,43 @@ public void AppliesWhenInstalledVersionIsOlder() Assert.Equal("0.0.10", staged!.Tag); } + [Fact(DisplayName = "起動できなかった配布物は、タグと digest の組で覚える")] + public void RemembersTheDistributionThatCouldNotStart() + { + var stage = CreateStage(); + Directory.CreateDirectory(_directory); + + // 覚える前は何にも当たらない。 + Assert.False(stage.IsKnownStartFailure("0.0.10", "abc123")); + + stage.RecordStartFailure("0.0.10", "abc123"); + + Assert.True(stage.IsKnownStartFailure("0.0.10", "abc123")); + + // 同じタグへ直した一式が上げ直されると digest が変わる。そちらは + // 取りに行けなければならない (release.yml の --clobber)。 + Assert.False(stage.IsKnownStartFailure("0.0.10", "def456")); + + // 別のタグも当然に通す。 + Assert.False(stage.IsKnownStartFailure("0.0.11", "abc123")); + + // digest を持たない候補は判断しようがない。止めるより通す。 + Assert.False(stage.IsKnownStartFailure("0.0.10", null)); + } + + [Fact(DisplayName = "起動できなかった記録は、取得を捨てても残る")] + public void TheStartFailureRecordSurvivesDiscard() + { + var stage = StageWith("0.0.10"); + stage.RecordStartFailure("0.0.10", "abc123"); + + // 戻す側は「取得を捨てる」と「覚える」を続けて行う。捨てるほうが + // 覚えたものまで消すと、次の確認でまた取りに行ってしまう。 + Assert.True(stage.Discard()); + + Assert.True(stage.IsKnownStartFailure("0.0.10", "abc123")); + } + [Fact(DisplayName = "同じパスの項目を持つ ZIP は展開せずに断る")] public void RefusesArchiveWithDuplicateEntries() {