Updater: distinguish 'no newer version' from 'check failed' on all three paths

Tech Singer's Windows 7 log from 2026-05-28 had this pattern:

  updater: GET https://api.github.com/repos/...
  updater: check failed: HttpRequestException: The SSL connection could not be established
  ...
  updater: startup check — up to date (v3.0.1)

i.e. the SSL handshake to GitHub failed (Win7's TLS stack missing
KB3140245 / KB4474419) but the updater logged 'up to date' and the
user-facing message in CheckForUpdatesManually said the same. So a
user with a broken update check has no way to distinguish that from
genuinely having the latest version.

Fix:
  * CheckForUpdateAsync now returns a discriminated UpdateCheckResult
    (UpdateAvailable / UpToDate / UpdateCheckFailed) instead of the
    old UpdateInfo?. Each return-null site is replaced with the
    appropriate concrete type.
  * The catch-all 'try { ... } catch (Exception ex) { return null; }'
    becomes 'return new UpdateCheckFailed(ClassifyFailure(ex), ...)'.
    ClassifyFailure walks the exception chain and maps to a coarse
    FailureKind enum: SecureConnection (TLS/auth), Timeout, HttpError,
    NetworkUnreachable. SecureConnection is broken out separately so
    the manual-check UI can point Win7 users at the specific Microsoft
    KBs that fix the issue.
  * MainForm.CheckForUpdatesManually pattern-matches on the result:
    UpToDate -> existing 'you're running the latest' message;
    UpdateAvailable -> existing install confirmation;
    UpdateCheckFailed -> NEW dialog (ShowUpdateCheckFailedDialog)
    whose wording is tailored to the FailureKind. The
    SecureConnection branch explicitly names KB3140245 and KB4474419
    and offers the manual zip-install URL as a fallback. All branches
    keep the technical detail out of the dialog and route it to the
    log instead.
  * Background and startup polls stay silent on UpToDate and
    UpdateCheckFailed (no point nagging the user about something
    they can't act on from a timer tick), but the startup-poll log
    now records the failure kind and detail instead of mislabelling
    the outcome as 'up to date'.

No version bump - this rides along with the next feature release
(planned v3.2 with the Reaper ReaStream integration). The bug is
silent on the affected users today, and shipping a v3.1.2 just for
the error-message improvement would mean another update cycle for
everyone for marginal benefit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-05-28 19:42:26 +01:00
co-authored by Claude Opus 4.7
parent aa099eb555
commit 6cbde0da12
2 changed files with 167 additions and 31 deletions
+81 -9
View File
@@ -60,7 +60,7 @@ internal sealed class RemSoundUpdater : IDisposable
/// limited, repo not found) or if the latest version is not newer than the running
/// assembly. Caller decides whether to surface "you're up to date" vs silently doing
/// nothing — both paths get null back.</summary>
public async Task<UpdateInfo?> CheckForUpdateAsync(CancellationToken token = default)
public async Task<UpdateCheckResult> CheckForUpdateAsync(CancellationToken token = default)
{
try
{
@@ -81,14 +81,14 @@ internal sealed class RemSoundUpdater : IDisposable
if (!resp.IsSuccessStatusCode)
{
Log?.Invoke($"updater: HTTP {(int)resp.StatusCode} from GitHub");
return null;
return new UpdateCheckFailed(FailureKind.HttpError, $"GitHub responded with HTTP {(int)resp.StatusCode}.");
}
await using var stream = await resp.Content.ReadAsStreamAsync(token).ConfigureAwait(false);
var releases = await JsonSerializer.DeserializeAsync<List<GitHubRelease>>(stream, JsonOpts, token).ConfigureAwait(false);
if (releases is null || releases.Count == 0)
{
Log?.Invoke("updater: releases list was empty");
return null;
return new UpdateCheckFailed(FailureKind.HttpError, "GitHub returned an empty release list.");
}
// Highest-versioned RemSound client release. Skip drafts, prereleases, and any
@@ -105,35 +105,61 @@ internal sealed class RemSoundUpdater : IDisposable
if (release?.TagName is null)
{
Log?.Invoke("updater: no RemSound client release found in the releases list");
return null;
return new UpdateCheckFailed(FailureKind.HttpError, "GitHub returned releases but none looked like a RemSound client release.");
}
var current = Assembly.GetExecutingAssembly().GetName().Version ?? new Version(0, 0, 0);
Log?.Invoke($"updater: current={current.ToString(3)} latest={latest.ToString(3)} ({release.TagName})");
if (latest <= current) return null;
if (latest <= current) return UpToDate.Instance;
var expectedAsset = AssetNameTemplate.Replace("{tag}", release.TagName);
var asset = release.Assets?.FirstOrDefault(a => string.Equals(a.Name, expectedAsset, StringComparison.OrdinalIgnoreCase));
if (asset?.BrowserDownloadUrl is null)
{
Log?.Invoke($"updater: latest release has no asset named '{expectedAsset}'");
return null;
return new UpdateCheckFailed(FailureKind.HttpError, $"The latest release page is missing the expected file '{expectedAsset}'.");
}
return new UpdateInfo(
return new UpdateAvailable(new UpdateInfo(
Tag: release.TagName,
Version: latest,
DownloadUrl: asset.BrowserDownloadUrl,
ReleaseNotes: release.Body ?? "",
ReleaseUrl: release.HtmlUrl ?? "");
ReleaseUrl: release.HtmlUrl ?? ""));
}
catch (Exception ex)
{
Log?.Invoke($"updater: check failed: {ex.GetType().Name}: {ex.Message}");
return null;
return new UpdateCheckFailed(ClassifyFailure(ex), ex.Message);
}
}
/// <summary>Maps a thrown exception from the GitHub HTTP call to a coarse-grained
/// <see cref="FailureKind"/> the UI can hang an honest plain-English message off without
/// quoting the underlying .NET exception type. Most "couldn't reach the server" errors
/// fall into the network bucket; the SSL bucket is broken out separately because it has
/// a specific cause and fix on Windows 7 (TLS 1.2 / SHA-2 Windows updates) that we want
/// to point users at when we see it. 2026-05-28.</summary>
private static FailureKind ClassifyFailure(Exception ex)
{
// Walk the exception chain — HttpRequestException is the outer wrapper; the actual
// cause (System.Net.Security.AuthenticationException, IOException, SocketException,
// etc) is in InnerException. Either layer might carry the diagnostic clue.
for (Exception? e = ex; e is not null; e = e.InnerException)
{
var typeName = e.GetType().Name;
var msg = e.Message ?? "";
if (typeName.Contains("Authentication", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("SSL", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("TLS", StringComparison.OrdinalIgnoreCase))
{
return FailureKind.SecureConnection;
}
}
if (ex is TaskCanceledException) return FailureKind.Timeout;
return FailureKind.NetworkUnreachable;
}
/// <summary>Filename of the one-shot "after the update restart, silently load this profile"
/// sentinel. Written next to RemSound.exe by <see cref="DownloadAndStageInstallAsync"/>
/// when the caller supplies a non-empty <c>activeProfileTitle</c>; read and deleted by
@@ -445,3 +471,49 @@ internal sealed record UpdateInfo(
string DownloadUrl,
string ReleaseNotes,
string ReleaseUrl);
/// <summary>Discriminated result of an update check. Replaces the v3.1.x-and-earlier
/// "UpdateInfo?" return type, which conflated "no newer version available" with "couldn't
/// reach the server" — the user saw "you are running the latest version" in both cases,
/// even when the check had actually failed because (e.g.) the OS couldn't establish a
/// secure connection to GitHub. The caller pattern-matches on this and shows an honest
/// message for each outcome. 2026-05-28.</summary>
internal abstract record UpdateCheckResult;
/// <summary>A newer release is available. Carries the parsed <see cref="UpdateInfo"/> the
/// caller passes to <see cref="RemSoundUpdater.DownloadAndStageInstallAsync"/>.</summary>
internal sealed record UpdateAvailable(UpdateInfo Info) : UpdateCheckResult;
/// <summary>The check completed and the installed version is at or above the latest
/// release. Singleton — there's nothing to carry beyond the result type itself.</summary>
internal sealed record UpToDate : UpdateCheckResult
{
public static readonly UpToDate Instance = new();
private UpToDate() { }
}
/// <summary>The check could not complete. <see cref="Kind"/> is a coarse classifier the UI
/// uses to pick a plain-English message; <see cref="TechnicalDetail"/> is the raw exception
/// or HTTP-status message intended for log output and "what to send the developer" cases —
/// never put it in a user-facing dialog verbatim.</summary>
internal sealed record UpdateCheckFailed(FailureKind Kind, string TechnicalDetail) : UpdateCheckResult;
/// <summary>Why the update check couldn't complete. Lets the UI distinguish "your TLS stack
/// is too old to reach modern HTTPS servers" (a known and fixable Windows 7 issue) from
/// "your internet is down" so the message and any pointers we offer match the actual
/// problem.</summary>
internal enum FailureKind
{
/// <summary>The HTTPS handshake itself failed — usually means the OS's TLS or
/// certificate stack is too old. Most commonly seen on Windows 7 installs without
/// the TLS 1.2 enablement update (KB3140245) and SHA-2 code signing support
/// (KB4474419).</summary>
SecureConnection,
/// <summary>The HTTP call reached GitHub but got back an unexpected response (4xx /
/// 5xx HTTP status, malformed JSON, empty release list, etc).</summary>
HttpError,
/// <summary>The HTTP call timed out.</summary>
Timeout,
/// <summary>Generic "couldn't reach the server" — DNS, socket, no internet.</summary>
NetworkUnreachable,
}