Author SHA1 Message Date
jaap-jan 281f849086 Merge pull request 'Look for a newer build the moment the application starts' (#8) from claude/version-check-startup-e06e9a into main
ci / build and test (push) Successful in 2m42s
ci / android head (push) Successful in 3m39s
ci / desktop nightly (push) Successful in 1m4s
ci / api image (push) Successful in 31s
Reviewed-on: #8
2026-08-12 10:40:04 +00:00
jaap-jan 25407756c3 Look for a newer build the moment the application starts
ci / build and test (pull_request) Successful in 2m18s
ci / desktop nightly (pull_request) Skipped
ci / android head (pull_request) Successful in 3m29s
ci / api image (pull_request) Successful in 5s
The first pass of the update loop waited two minutes. Every pass after it came
six hours apart, which is the right interval for a product that ships rarely —
but the delay in front of the first one quietly excluded a whole way of using
this application.

A client opened to reach one host and closed again is over before the two
minutes are. Used that way, it never checks at all: not once, not slowly, never.
That is precisely the machine ADR 0011 names as the real cost of distributing
outside a store — quietly a year behind — and the galling part is that the
mechanism to fix it was switched on the whole time and simply never reached.

The delay's own argument is recorded in the diff it is being removed from, and
it was not a bad one: nothing anybody does in their first two minutes depends on
an update, and launch is already contending for the network with a schema
migration, a resumed sign-in and a first sync, at the one moment somebody is
watching the window. What it weighed was the cost of checking early against the
benefit of checking early. It never weighed the cost of not checking at all.

◆ THE YIELD IS WHAT KEEPS THIS OFF THE LAUNCH PATH, AND IT IS NOT DECORATION.
Start() is called from MainWindowViewModel.StartAsync ahead of the migration, so
an inline first pass would run whatever the channel does before its own first
await — Velopack reads the install layout from disk — between the user and their
window. Yielding hands the rest of launch back and puts the check in a later
turn, which is the same moment in every sense anybody can perceive and none of
the cost. So the answer to the delay's argument is not that it was wrong; it is
that a yield buys most of what two minutes bought.

Task.Yield takes no token where Task.Delay did, so the loop body now observes
cancellation at its head. Without that, an application closed during launch
spends its last moment asking a release channel about a build it will not run.

Two things deliberately not changed. The AUTOMATIC UPDATE CHECKS preference
still gates the pass — "on start" means every start, not regardless of what the
user asked for, and that setting is already on by default. And the data cost is
unchanged rather than merely acceptable: a check is a few hundred bytes and the
download only follows if something newer exists, so this moves the same traffic
earlier without adding any. That matters most on the phone, where the same loop
runs against AndroidUpdateChannel.

TheFirstPassRunsAtStart_RatherThanOnADelay drives the real loop rather than
CheckOnceAsync, which is the one thing that file otherwise avoids — and here it
is the point, because the claim is about when the pass happens rather than what
it does. It waits on the pass and not on a clock, so there is nothing to be
flaky about: a regression that puts a delay back does not fail on a margin, it
spins until the suite's own cancellation ends it. DisposingStopsTheLoop keeps
its assertion and gains a note that it is now a race rather than a formality.

§16.7 of the manual checks gains the sentence that reopening the application
does what CHECK NOW does. It is the step somebody following that section would
otherwise discover by accident.

447 App tests and 153 layout tests pass.
2026-08-12 12:03:54 +02:00
jaap-jan a763f4b113 Merge pull request 'Stop the SDK's own trimmer version deciding whether CI can restore' (#11) from claude/illink-lock-drift into main
ci / desktop nightly (push) Successful in 1m17s
ci / api image (push) Successful in 37s
ci / build and test (push) Successful in 2m41s
ci / android head (push) Successful in 3m38s
Reviewed-on: #11
2026-08-12 10:03:22 +00:00
jaap-jan b80bf23341 Merge pull request 'Stop one tab's status banner from speaking for all the others' (#10) from claude/status-bar-tab-isolation-caa52b into main
ci / build and test (push) Failing after 8s
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Failing after 7s
Reviewed-on: #10
2026-08-12 09:37:55 +00:00
jaap-jan 8c58e5a558 Stop one tab's status banner from speaking for all the others
ci / build and test (pull_request) Failing after 10s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Skipped
ci / android head (pull_request) Failing after 6s
A shell that ended printed "The remote closed the session." into the status
banner at the foot of the terminal. Switch to a tab whose shell was still very
much alive and the sentence was still there, sitting under a live prompt and
describing a terminal that was no longer on screen.

There is one #status element for the whole page, because there is one page for
every terminal — the panes are stacked in the same box and all but the active one
are hidden — and SESSION_CLOSED wrote its reason straight into it. The other half
of the same mistake ran the other way: SESSION_OPENED and SESSION_REMOVED both
cleared the element outright, so opening or closing any tab wiped a message that
belonged to a different one. Whichever tab spoke last owned the banner.

The fix is to separate the two things that were being put in one place by who
they are actually true of. A session's last words are a fact about one terminal
and are now held on the session record, drawn only while that session's pane is
the one showing; activate() re-renders, so the banner follows the tab and a dead
tab still says what became of it when you come back to it. The socket's own state
— "Connecting…", "Reconnecting the terminal view…" — stays page-wide, because
there is a single socket behind every pane, and it wins when both have something
to say: a page whose socket is down is not showing live output on any pane.

A SESSION_CLOSED for a session this page has no pane for is now dropped rather
than printed. There is nothing to attach it to, and putting it in the banner
anyway is precisely the bug in miniature.

Verified by driving the real handleFrame through a stub DOM under node, which is
as close as this repo gets — there is no JS test harness and CI runs dotnet only,
so nothing here is a standing test. Twelve checks over open, close, switch,
reopen, remove and a socket drop pass against this file; the same script run
against the previous one reproduces the report exactly, epitaph under a live tab
included. Not seen in a running app: no C# changed, and the page is unreachable
without one.
2026-08-12 11:28:01 +02:00
4 changed files with 145 additions and 26 deletions
+3 -1
View File
@@ -2423,7 +2423,9 @@ script warns rather than failing when that is legitimate, which is the first rel
### 16.7 The update arrives, and the restart lands in it · **the whole point of the work** ### 16.7 The update arrives, and the restart lands in it · **the whole point of the work**
With v0.1.0 installed and running, a vault unlocked, a host change made, and **a terminal open**, publish With v0.1.0 installed and running, a vault unlocked, a host change made, and **a terminal open**, publish
v0.1.1 (`-Upload`). Then press CHECK NOW on Settings → General rather than waiting six hours. v0.1.1 (`-Upload`). Then press CHECK NOW on Settings → General rather than waiting six hours. Closing and
reopening the application does the same thing without the button: the first pass of the loop runs at launch,
so a client started after a release finds it without anybody asking.
**Pass:** the progress bar moves, the banner appears above the status bar, and — the part to actually watch **Pass:** the progress bar moves, the banner appears above the status bar, and — the part to actually watch
— the terminal **reflows cleanly rather than being sliced**, with the remote seeing the smaller row count. — the terminal **reflows cleanly rather than being sliced**, with the remote seeing the smaller row count.
@@ -74,16 +74,6 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
/// </remarks> /// </remarks>
private static readonly TimeSpan CheckInterval = TimeSpan.FromHours(6); private static readonly TimeSpan CheckInterval = TimeSpan.FromHours(6);
/// <summary>How long to wait before the first pass.</summary>
/// <remarks>
/// A delay, where <c>VaultViewModel</c>'s sync loop runs a pass immediately. The difference is what the
/// user is waiting for: a vault edited on another machine should be current by the time they have
/// finished reading the list, whereas nothing anybody does in their first two minutes depends on an
/// update. Launch is already contending for the network and the CPU with a schema migration, a resumed
/// sign-in and a first sync, at the one moment somebody is watching the window.
/// </remarks>
private static readonly TimeSpan FirstCheckDelay = TimeSpan.FromMinutes(2);
private readonly IUpdateChannel updates; private readonly IUpdateChannel updates;
private readonly ClientSettingsStore settings; private readonly ClientSettingsStore settings;
private readonly TimeProvider clock; private readonly TimeProvider clock;
@@ -242,16 +232,40 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
loop = RunCheckLoopAsync(lifetime.Token); loop = RunCheckLoopAsync(lifetime.Token);
} }
/// <remarks>
/// <para>
/// <b>The first pass runs at launch, with no delay in front of it.</b> It used to wait two minutes, on
/// the argument that nothing anybody does in their first two minutes depends on an update and launch is
/// already contending for the network with a schema migration, a resumed sign-in and a first sync. What
/// that argument leaves out is the run that is over before the two minutes are: a client opened to reach
/// one host and closed again never checks at all, and a machine used that way is exactly the one ADR
/// 0011 warns about — quietly a year behind, with the mechanism to fix it switched on and never reached.
/// Every start now asks.
/// </para>
/// <para>
/// <b>The yield is what keeps that off the launch path.</b> <see cref="Start"/> is called from
/// <c>MainWindowViewModel.StartAsync</c> before the migration, so running the pass inline would put
/// whatever the channel does before its own first await — Velopack reads the install layout from disk —
/// between the user and their window. Yielding hands the rest of the launch back and lets the check run
/// in a later turn, which is the same moment in every sense that matters and none of the cost.
/// </para>
/// </remarks>
private async Task RunCheckLoopAsync(CancellationToken cancellationToken) private async Task RunCheckLoopAsync(CancellationToken cancellationToken)
{ {
try try
{ {
await Task.Delay(FirstCheckDelay, clock, cancellationToken).ConfigureAwait(true); await Task.Yield();
using var timer = new PeriodicTimer(CheckInterval, clock); using var timer = new PeriodicTimer(CheckInterval, clock);
do do
{ {
// Task.Yield takes no token, unlike the delay it replaced, so a shutdown that lands while
// the loop is waiting to be handed back the thread has to be observed here rather than
// only at the next tick. Otherwise an application closed during launch spends its last
// moment asking a release channel about a build it is not going to run.
cancellationToken.ThrowIfCancellationRequested();
await CheckOnceAsync(cancellationToken).ConfigureAwait(true); await CheckOnceAsync(cancellationToken).ConfigureAwait(true);
} }
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true)); while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true));
+77 -14
View File
@@ -84,14 +84,57 @@ const RELEASE_FOCUS_MESSAGE = 'dodossh.release-focus';
const root = document.getElementById('root'); const root = document.getElementById('root');
const statusBanner = document.getElementById('status'); const statusBanner = document.getElementById('status');
/** @type {Map<number, {term: object, fit: object, pane: HTMLElement}>} */ /** @type {Map<number, {term: object, fit: object, pane: HTMLElement, notice: string}>} */
const sessions = new Map(); const sessions = new Map();
/** @type {WebSocket | null} */ /** @type {WebSocket | null} */
let socket = null; let socket = null;
function setStatus(text) { /** Whose pane is showing, or null before there is one — see activate(). */
statusBanner.textContent = text ?? ''; let activeSessionId = null;
/*
THE BANNER BELONGS TO ONE PANE AT A TIME
There is one #status element for the whole page, because there is one page for every terminal: the
panes are stacked in the same box and all but the active one are hidden. What goes in it comes from
two sources that are not the same size, and the difference is the whole of this.
The socket's troubles are the page's. There is a single socket behind every pane, so "the view is
reconnecting" is true of whatever is on screen and true of the panes behind it.
A session's last words are not. "The remote closed the session." is a fact about one terminal and says
nothing whatever about the others so it is held on the session and drawn only while that session's
pane is the one showing. Written straight into the shared element, which is what this used to do, it
outlived the tab it described: switching to a live terminal left the dead one's epitaph sitting under
it, and opening or closing any other tab wiped the message whether or not it belonged to that tab.
The socket's half wins when both have something to say: a page whose socket is down is not showing
live output on any pane, which makes what became of one session the less urgent of the two.
*/
let transportStatus = statusBanner.textContent ?? '';
function renderStatus() {
const notice = activeSessionId === null ? '' : sessions.get(activeSessionId)?.notice ?? '';
statusBanner.textContent = transportStatus || notice;
}
/** Says something about the socket, which every pane shares. */
function setTransportStatus(text) {
transportStatus = text ?? '';
renderStatus();
}
/** Records what became of one session, to be drawn only while that session's pane is showing. */
function setSessionNotice(sessionId, text) {
const session = sessions.get(sessionId);
if (!session) {
return;
}
session.notice = text ?? '';
renderStatus();
} }
/** Builds a frame: opcode, big-endian session id, then payload. */ /** Builds a frame: opcode, big-endian session id, then payload. */
@@ -292,7 +335,7 @@ function createSession(sessionId) {
term.onResize(() => sendResize(sessionId, term, pane)); term.onResize(() => sendResize(sessionId, term, pane));
const session = { term, fit, pane }; const session = { term, fit, pane, notice: '' };
sessions.set(sessionId, session); sessions.set(sessionId, session);
activate(sessionId); activate(sessionId);
@@ -306,6 +349,11 @@ function activate(sessionId) {
session.pane.dataset.active = String(id === sessionId); session.pane.dataset.active = String(id === sessionId);
} }
// The banner follows the pane. Whatever this session has to say for itself replaces whatever the
// session that was showing had to say for its own, which is the point of holding it per session.
activeSessionId = sessionId;
renderStatus();
const active = sessions.get(sessionId); const active = sessions.get(sessionId);
if (active) { if (active) {
active.term.focus(); active.term.focus();
@@ -373,7 +421,10 @@ function handleFrame(buffer) {
session.term.write(REPLAY_BANNER); session.term.write(REPLAY_BANNER);
} }
setStatus(''); // This session's own line, and only this one's: a session that is open has nothing to say about
// how it ended. The page's own "Connecting…" is cleared by the socket opening, which happens
// before any frame can arrive.
setSessionNotice(sessionId, '');
break; break;
} }
@@ -426,7 +477,14 @@ function handleFrame(buffer) {
session.pane.remove(); session.pane.remove();
sessions.delete(sessionId); sessions.delete(sessionId);
setStatus(''); // The notice went with the session record it was held on, but the page can still be pointing at
// the pane that is now gone. Cleared rather than left dangling, so the banner stops describing a
// closed tab while the host decides which pane to show next.
if (activeSessionId === sessionId) {
activeSessionId = null;
}
renderStatus();
break; break;
} }
@@ -503,14 +561,19 @@ function handleFrame(buffer) {
const session = sessions.get(sessionId); const session = sessions.get(sessionId);
const reason = new TextDecoder().decode(payload); const reason = new TextDecoder().decode(payload);
if (session) { if (!session) {
// The pane and its scrollback stay. The user was probably reading the last thing the // No pane, so there is nothing this page can honestly hang the reason on. It used to go into
// remote said, and that is usually why the session ended. // the banner anyway, which printed one session's ending underneath whichever pane happened to
session.term.write(`\r\n\x1b[38;5;244m── ${reason} ──\x1b[0m\r\n`); // be showing at the time.
session.term.options.cursorBlink = false; break;
} }
setStatus(reason); // The pane and its scrollback stay. The user was probably reading the last thing the
// remote said, and that is usually why the session ended.
session.term.write(`\r\n\x1b[38;5;244m── ${reason} ──\x1b[0m\r\n`);
session.term.options.cursorBlink = false;
setSessionNotice(sessionId, reason);
break; break;
} }
@@ -535,7 +598,7 @@ function scheduleReconnect() {
return; return;
} }
setStatus('Reconnecting the terminal view…'); setTransportStatus('Reconnecting the terminal view…');
reconnectTimer = setTimeout(() => { reconnectTimer = setTimeout(() => {
reconnectTimer = null; reconnectTimer = null;
@@ -555,7 +618,7 @@ function connect() {
socket.binaryType = 'arraybuffer'; socket.binaryType = 'arraybuffer';
socket.addEventListener('open', () => { socket.addEventListener('open', () => {
setStatus(''); setTransportStatus('');
// Back to the quick attempt for whatever the next failure turns out to be. Kept slow between // Back to the quick attempt for whatever the next failure turns out to be. Kept slow between
// attempts within one outage, reset once the outage is actually over. // attempts within one outage, reset once the outage is actually over.
@@ -403,6 +403,46 @@ public sealed class UpdateFlowTests : IDisposable
channel.Checks.ShouldBe(1); channel.Checks.ShouldBe(1);
} }
/// <remarks>
/// <para>
/// The loop rather than <c>CheckOnceAsync</c>, which is the one thing the rest of this file avoids
/// driving — and here it is the whole point, because the claim is about when the first pass happens
/// rather than about what it does. The first pass used to wait two minutes, which meant a client opened
/// to reach one host and closed again never asked at all.
/// </para>
/// <para>
/// It waits on the pass and not on a clock, so there is nothing here to be flaky about: a regression
/// that puts a delay back in front of the loop does not fail on a margin, it spins until the suite's own
/// cancellation ends it.
/// </para>
/// </remarks>
[Fact]
public async Task TheFirstPassRunsAtStart_RatherThanOnADelay()
{
channel.Available = new AvailableUpdate("1.3.0");
var updates = Build();
await using var _ = updates.ConfigureAwait(false);
updates.Start();
while (updates.State is not UpdateState.Ready)
{
Token.ThrowIfCancellationRequested();
await Task.Yield();
}
channel.Checks.ShouldBe(1);
updates.ReadyVersion.ShouldBe("1.3.0");
}
/// <remarks>
/// Started and disposed with nothing in between, which since the first pass stopped waiting two minutes
/// is a race rather than a formality: the loop may be anywhere between its yield and a finished check
/// when the cancellation lands. What is asserted is what matters either way — that disposing returns,
/// rather than waiting on a pass that will never be allowed to finish.
/// </remarks>
[Fact] [Fact]
public async Task DisposingStopsTheLoop() public async Task DisposingStopsTheLoop()
{ {