Public Access
Stop the updater offering the build it saw first, whatever has been published since
A client two versions behind was offered the older of the two, installed it,
restarted, and was told at once that another update was waiting. Two downloads,
two installs and two restarts to move one step.
The feed was not the problem and could not have been. The nightly release is
deleted and remade on every push to main, so it holds exactly one build: a client
can only ever be offered the newest thing there is. The wrong version came from
this application's own state.
◆ THE PASS STOPPED ASKING, WHICH IS NOT WHAT IT WAS MEANT TO STOP DOING.
if (!IsAutomatic || State is UpdateState.Downloading or UpdateState.Ready)
Ready means something is downloaded and waiting. The intent behind excluding it is
right — there is no point fetching what has already been fetched — and what it
actually did was skip the check. So from the first thing any process downloaded,
its banner was pinned to that build for the rest of its life, however many newer
ones were published afterwards. On a rolling channel that is the normal case
rather than a corner.
Not re-fetching is still the rule and now lives in FetchAsync, which compares
versions rather than refusing to look: the same build is left alone, a newer one
replaces what was pending. Which is also why FetchAsync answers with what is
*pending* rather than with what the check returned — a client on 1.0 holding 1.1
is told 1.1 by every later check, because 1.1 is still the newest thing on the
feed, so "the check found something" and "there is something new" are different
questions and only the second one is worth a download.
A build already fetched is never discarded, including when a check answers null.
Null means the feed's newest is no newer than the build that is *running*, which
says nothing about the one on disk — that one is still newer, and withdrawing an
offer somebody has already been shown would be the worse answer.
◆ AND A LAST LOOK BEFORE THE PROCESS IS REPLACED, WHICH IS THE HALF THE TIMER
CANNOT REACH.
The loop asks every six hours. The build that caused this was published minutes
before RESTART NOW was pressed, so no interval anybody would accept would have
caught it. The press now asks once more.
It costs one small request in the ordinary case, because the feed answers with the
build already on disk and nothing is fetched. It costs a download only when there
genuinely is something newer, which is the case where the alternative was two
downloads anyway.
Bounded at ten seconds, and the wait rather than the request: VelopackUpdateChannel
records that Velopack's check takes no token, so cancelling would be observed only
after the call had already returned. Ten seconds bounds a small JSON document
against a network that drops packets rather than refusing them; a refused
connection fails in milliseconds and never reaches it.
Nothing here refuses a restart. An unreachable forge, a look that ran out of time,
a fetch of the newer build that failed halfway — all leave the build on disk
untouched and still newer than the one running, so the restart goes ahead with it.
Hence swallowed rather than reported: Failed is for something a person is waiting
on an answer to, and this person is waiting on a restart they are about to get.
IsRestarting keeps the banner up through it. Everything the strip draws keys off
IsReady and the look moves the state off that, so without it pressing RESTART NOW
made the banner vanish with nothing on screen saying why, for as long as a 120 MB
package takes. It says what it is doing instead, percentage and all.
◆ THE CHANNEL HAD TO LEARN WHICH BUILD IT FETCHED, AS AGAINST WHICH IT LAST FOUND.
Required by both halves above rather than tidied along with them. Matched()
resolved an AvailableUpdate only against `found`, which a check overwrites — and
both changes make this application check while a build is already downloaded. A
check that lands a newer answer and then fails to fetch it, or is given up on
halfway, leaves the shell restarting with the build it already has and Matched()
throwing at it: the offer real, the package on disk real, and the only thing wrong
being that the channel had forgotten which one it was. A slow forge became a
refused restart.
So `fetched` is recorded after a download returns, tried before `found`, and
survives every later check.
Settings → General claimed checks start "a couple of minutes after launch", which
has been untrue since the first pass moved to launch. It now also says the loop
keeps asking while an update is waiting, because that is the behaviour somebody
reading that row would want to know about.
UpdateFlowTests goes from 21 to 28: the pass asks again and fetches once, the
pending build is replaced when the feed moves on, a superseded offer reappears
through a dismissal, the press installs what the last look finds, and the three
ways the last look can fail all still install what was already on disk. Check
16.7a is the manual half, needing two real releases.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1908189100
commit
56c19b006d
5 files changed
+536
-35
No files matched your search
@@ -390,8 +390,14 @@ public sealed class UpdateFlowTests : IDisposable
|
||||
updates.State.ShouldBe(UpdateState.Ready);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Asks, and does not fetch again. The distinction is the whole of the fix for a client that installed
|
||||
/// two updates to move one step: the old guard skipped the <em>pass</em> once anything was ready, so the
|
||||
/// banner was pinned to whatever build the process happened to see first. Not re-fetching is still the
|
||||
/// rule; it is enforced by comparing versions now rather than by refusing to look.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task APassWhileOneIsAlreadyReady_DoesNotAskAgain()
|
||||
public async Task APassWhileOneIsAlreadyReady_AsksAgainAndDoesNotFetchTwice()
|
||||
{
|
||||
channel.Available = new AvailableUpdate("1.3.0");
|
||||
|
||||
@@ -400,7 +406,181 @@ public sealed class UpdateFlowTests : IDisposable
|
||||
await updates.CheckOnceAsync(Token);
|
||||
await updates.CheckOnceAsync(Token);
|
||||
|
||||
channel.Checks.ShouldBe(1);
|
||||
channel.Checks.ShouldBe(2);
|
||||
channel.Downloads.ShouldBe(1, "the second pass found the build it had already fetched");
|
||||
updates.State.ShouldBe(UpdateState.Ready);
|
||||
updates.ReadyVersion.ShouldBe("1.3.0");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// ◆ The regression this whole change is about, in the case the timer can reach. A rolling nightly
|
||||
/// channel keeps exactly one build, so a client left running does not accumulate a queue of updates — it
|
||||
/// is offered whichever one the feed holds, and the pending build has to be replaced when that moves on.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task APassWhileOneIsAlreadyReady_ReplacesItWhenTheFeedHasMovedOn()
|
||||
{
|
||||
channel.Available = new AvailableUpdate("1.3.0");
|
||||
|
||||
var updates = Build();
|
||||
|
||||
await updates.CheckOnceAsync(Token);
|
||||
updates.ReadyVersion.ShouldBe("1.3.0");
|
||||
|
||||
channel.Available = new AvailableUpdate("1.4.0");
|
||||
|
||||
await updates.CheckOnceAsync(Token);
|
||||
|
||||
channel.Downloads.ShouldBe(2);
|
||||
updates.State.ShouldBe(UpdateState.Ready);
|
||||
updates.ReadyVersion.ShouldBe("1.4.0");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A build superseded after LATER was pressed brings the banner back, because LATER was an answer about
|
||||
/// a version and this is a different one.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ASupersededOffer_IsShownAgainEvenIfTheOldOneWasDismissed()
|
||||
{
|
||||
channel.Available = new AvailableUpdate("1.3.0");
|
||||
|
||||
var updates = Build();
|
||||
|
||||
await updates.CheckOnceAsync(Token);
|
||||
updates.DismissBannerCommand.Execute(null);
|
||||
updates.IsBannerShowing.ShouldBeFalse();
|
||||
|
||||
channel.Available = new AvailableUpdate("1.4.0");
|
||||
await updates.CheckOnceAsync(Token);
|
||||
|
||||
updates.IsBannerShowing.ShouldBeTrue();
|
||||
updates.ReadyHeadline.ShouldContain("1.4.0");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// ◆ The half the timer cannot reach, and the one a person actually hit: a build published between the
|
||||
/// last pass and the press. Without this the press installs a build that was already superseded, and the
|
||||
/// next launch offers the newer one straight away — two downloads, two installs and two restarts to move
|
||||
/// one step.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task Restarting_TakesOneLastLookAndInstallsWhatItFinds()
|
||||
{
|
||||
channel.Available = new AvailableUpdate("1.3.0");
|
||||
|
||||
var updates = Build();
|
||||
|
||||
await updates.CheckOnceAsync(Token);
|
||||
|
||||
// Published while the banner was up, which on a rolling channel replaces the release rather than
|
||||
// adding to it — so the feed now holds only this one.
|
||||
channel.Available = new AvailableUpdate("1.4.0");
|
||||
|
||||
await updates.RestartNowCommand.ExecuteAsync(null);
|
||||
|
||||
channel.Restarts.ShouldBe(1);
|
||||
channel.RestartedWith!.Version.ShouldBe("1.4.0");
|
||||
restarts.ShouldBe(1, "one restart, not one per build");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The ordinary case, and what it costs: one question and no download. A press that re-fetched what was
|
||||
/// already on disk would have made the fix worse than the bug.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task Restarting_AsksOnceMoreAndFetchesNothingWhenNothingIsNewer()
|
||||
{
|
||||
channel.Available = new AvailableUpdate("1.3.0");
|
||||
|
||||
var updates = Build();
|
||||
|
||||
await updates.CheckOnceAsync(Token);
|
||||
await updates.RestartNowCommand.ExecuteAsync(null);
|
||||
|
||||
channel.Checks.ShouldBe(2);
|
||||
channel.Downloads.ShouldBe(1);
|
||||
channel.RestartedWith!.Version.ShouldBe("1.3.0");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The look is a courtesy and never a gate. Somebody pressing RESTART NOW on a train has a build on disk
|
||||
/// that is newer than the one running, and a forge that cannot be reached must not stand between them.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task RestartingWhenTheLastLookFails_StillInstallsWhatWasAlreadyFetched()
|
||||
{
|
||||
channel.Available = new AvailableUpdate("1.3.0");
|
||||
|
||||
var updates = Build();
|
||||
|
||||
await updates.CheckOnceAsync(Token);
|
||||
|
||||
channel.CheckFailure = new HttpRequestException("no such host");
|
||||
|
||||
await updates.RestartNowCommand.ExecuteAsync(null);
|
||||
|
||||
channel.Restarts.ShouldBe(1);
|
||||
channel.RestartedWith!.Version.ShouldBe("1.3.0");
|
||||
updates.State.ShouldNotBe(UpdateState.Failed, "nothing the user asked for failed");
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="RestartingWhenTheLastLookFails_StillInstallsWhatWasAlreadyFetched" />
|
||||
/// <remarks>
|
||||
/// The same rule one step further in: the newer build was found and then could not be fetched. What is
|
||||
/// on disk is untouched, so that is what gets installed.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task RestartingWhenTheNewerFetchFails_StillInstallsWhatWasAlreadyFetched()
|
||||
{
|
||||
channel.Available = new AvailableUpdate("1.3.0");
|
||||
|
||||
var updates = Build();
|
||||
|
||||
await updates.CheckOnceAsync(Token);
|
||||
|
||||
channel.Available = new AvailableUpdate("1.4.0");
|
||||
channel.DownloadFailure = new HttpRequestException("the connection was reset");
|
||||
|
||||
await updates.RestartNowCommand.ExecuteAsync(null);
|
||||
|
||||
channel.Restarts.ShouldBe(1);
|
||||
channel.RestartedWith!.Version.ShouldBe("1.3.0");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Everything the banner draws keys off <c>IsReady</c>, and the last look moves the state off it — so
|
||||
/// without <c>IsRestarting</c> the strip vanished the moment RESTART NOW was pressed and left nothing on
|
||||
/// screen saying why, for as long as a package takes to fetch.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheBannerStaysUpWhileARestartFetchesANewerBuild()
|
||||
{
|
||||
channel.Available = new AvailableUpdate("1.3.0");
|
||||
|
||||
var updates = Build();
|
||||
|
||||
await updates.CheckOnceAsync(Token);
|
||||
|
||||
channel.Available = new AvailableUpdate("1.4.0");
|
||||
channel.HoldDownload = new TaskCompletionSource();
|
||||
|
||||
var pressed = updates.RestartNowCommand.ExecuteAsync(null);
|
||||
|
||||
// The gate is what makes the intermediate state observable; without it the fetch is over before
|
||||
// anything could look at it.
|
||||
while (updates.State is not UpdateState.Downloading)
|
||||
{
|
||||
await Task.Yield();
|
||||
}
|
||||
|
||||
updates.IsBannerShowing.ShouldBeTrue();
|
||||
updates.ReadyHeadline.ShouldContain("before restarting");
|
||||
|
||||
channel.HoldDownload.SetResult();
|
||||
await pressed;
|
||||
|
||||
channel.RestartedWith!.Version.ShouldBe("1.4.0");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
|
||||
Reference in new issue
Block a user