Author SHA1 Message Date
jaap-jan ef12e8cc99 Stop the data plane's tests racing the socket they just connected
ci / build and test (pull_request) Successful in 2m30s
ci / android head (pull_request) Successful in 3m19s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Successful in 21s
CI went red on the run that added the renderer tests, and not on anything they
assert: TerminalDataPlaneTests.Output_ReachesTheRenderer read the output frame
where it expected the session's opening one, having lost a race that has been
in the helper since it was written.

Connected and attached are two different moments. ClientWebSocket.ConnectAsync
completes on the 101, which UpgradeAsync writes before it has a WebSocket to
attach — it builds one from the stream and swaps it in a few instructions
later, on the accept thread. SendAsync drops anything sent in between, which is
the transport's documented contract rather than a bug: there is nowhere to put
a frame for a renderer that is not there, and queueing it is the unbounded
growth the credit window exists to prevent. So a helper that returned on the
handshake and let its caller send immediately was betting on thread scheduling,
every run, on every test in the file.

It only started losing now because that assembly grew nine tests and a
JavaScript engine to run them in, which is more work in parallel with a window
measured in instructions. The race is older than the branch that exposed it.

ConnectAsync now waits for the plane's own SocketAttached, subscribed before
the connection because the event can be over before ConnectAsync returns, and
bounded so a socket that never attaches fails the helper instead of hanging the
suite in a later receive. TerminalWorkspaceTests.ConnectRendererAsync has the
same exposure through OpenSessionAsync's opening frame and now waits on
WaitForRendererAsync, with a note on why that is enough for the reattach tests
and what would stop being enough.

◆ NOTHING IN src CHANGED, AND THAT IS THE CONCLUSION RATHER THAN THE SHORTCUT.
Production waits for exactly this moment already — every path that opens a
session goes through TerminalWorkspace.WaitForRendererAsync, which resolves
from the same few lines that raise the event this helper now waits on. Only the
tests skipped the gate the application does not.

Verified by widening the window rather than by hunting the flake: a 100ms delay
inserted between the 101 and the attach, in a throwaway tree, hangs the old
helper outright — both frames dropped, the test blocked in receive — and passes
89/89 with this one. Green three times over on the CI platform besides
(Alpine, musl, Release, dotnet/sdk:10.0-alpine).
2026-08-14 15:44:03 +02:00
jaap-jan 963cb7f670 Reconnect the terminal view when somebody comes back to it
ci / build and test (pull_request) Failing after 2m29s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Skipped
ci / android head (pull_request) Successful in 3m23s
Going back to a terminal left alone for a while found it stuck on
"Reconnecting the terminal view…", and stuck is the right word: the banner
stayed and nothing behind it was reconnecting.

The page's whole recovery story was a setTimeout chain, and a chain is exactly
what a WebView is entitled to stop running. Chromium throttles timers in a page
nobody is looking at — down to once a minute once it has been hidden five
minutes — and a renderer that is frozen, or reclaimed and not yet reloaded,
runs none of them. So the socket drops while nobody is watching, the banner
goes up, the retry is scheduled, and the retry is then the one thing not
running.

Three defects, each of which leaves that banner up for the rest of the page's
life.

◆ NOTHING LISTENED FOR THE PAGE COMING BACK. The only thing that could clear
the banner was a timer that may never fire. terminal.js now reconnects on
visibilitychange, focus and online — the events that mean somebody is looking
again, and the ones that cannot be throttled — cancelling the pending timer and
resetting the backoff. Over a healthy socket all three do nothing, which is
what makes them safe to fire as often as clicking a window does.

◆ A HANDSHAKE THAT NEVER FINISHED WAS INVISIBLE. Every retry was scheduled by a
close or an error, so an attempt parked in CONNECTING — which is what a
suspended renderer leaves behind — scheduled nothing at all, ever. There is now
a five-second watchdog on the handshake.

◆ STALE SOCKETS SCHEDULED RETRIES, AND THAT ONE IS A LOOP RATHER THAN A STALL.
connect() never detached the old socket's handlers, and the host aborts the
displaced socket on takeover — TerminalDataPlane.UpgradeAsync, doing exactly
what it should. That close read as a fresh failure and scheduled a retry
against the socket that had just succeeded, whose own close scheduled the next:
no fixed point, reconnecting every second forever with the banner up for most
of it. Every handler now asks whether it is still the page's own attempt, and
connect() closes what it abandons.

◆ WHICH OF THE PLATFORM BEHAVIOURS ACTUALLY BIT IS NOT ESTABLISHED, and the fix
does not depend on knowing. Throttled timers, a frozen renderer and a reclaimed
one all end at the same dead timer; guessing between them would have produced a
narrower fix for one of the three.

THE TEST RUNS terminal.js ITSELF, in a fake browser, inside dotnet test.
RendererPage loads the file the shell project ships — not a transcription of its
logic into C#, which would be a copy that stays correct while the page rots —
into a Jint engine, one per test, over a harness that fakes a WebSocket and a
clock and nothing else. Jint rather than a node script because CI would run the
node one and nobody's inner loop would; the cost is that Jint is not Chromium,
so this proves the page's logic and nothing about how a WebView behaves. That
line is drawn in RendererPage's remark and picked up by two new manual checks,
1.10 for the desktop and 11.12a for the phone, which own the platform half.

Four of the nine tests fail against the page as it stood — the stale close, the
parked handshake, and the two wake-ups. Two more assert that a wake-up over a
healthy socket does nothing, and pass against either version on purpose: they
are what stops the cure being worse.

Left alone deliberately: a socket that is open and dead shows no banner at all,
because readyState still reads OPEN. That looks like a terminal that swallows
what is typed, needs a liveness probe rather than a faster retry, and is written
down at the end of 11.12a rather than quietly bundled in here.
2026-08-14 15:09:43 +02:00
jaap-jan 68964d8a34 Merge pull request 'Name a quick-connect result's vault, and search on it' (#13) from claude/search-items-vault-display-865eb0 into main
ci / build and test (push) Successful in 3m42s
ci / api image (push) Successful in 37s
ci / android head (push) Successful in 3m46s
ci / desktop nightly (push) Successful in 44s
Reviewed-on: #13
2026-08-14 09:13:16 +00:00
jaap-jan 6934efb1e6 Merge pull request 'Take the SSH.NET release that fixes the SCP path traversal' (#12) from claude/sharp-fermat-9b8dc5 into main
ci / android head (push) Canceled after 0s
ci / desktop nightly (push) Canceled after 0s
ci / api image (push) Canceled after 0s
ci / build and test (push) Canceled after 5s
Reviewed-on: #12
2026-08-14 09:13:10 +00:00
jaap-jan e252d337d1 Take the SSH.NET release that fixes the SCP path traversal
ci / desktop nightly (pull_request) Skipped
ci / android head (pull_request) Successful in 3m26s
ci / build and test (pull_request) Successful in 2m27s
ci / api image (pull_request) Successful in 21s
Every project in the repository stopped building, on a warning about a package
none of them had changed:

    error NU1903: Warning As Error: Package 'SSH.NET' 2025.1.0 has a known high
    severity vulnerability

GHSA-q939-rpr3-3284 was published after the pin was written. ScpClient.Download
in recursive mode takes the filenames the server sends and joins them to the
local destination without validating them, so a malicious or compromised SCP
server can name "../" its way out of the download directory and write anywhere
the client process can — CWE-22, 7.1, and the same shape as OpenSSH's
CVE-2019-6111 with the traversal left in. Everything up to and including
2025.1.0 is affected; 2026.0.0 is the fix.

◆ THIS APPLICATION WAS NEVER EXPOSED, AND THE UPGRADE IS STILL THE RIGHT ANSWER.
Nothing here constructs a ScpClient. File transfer is SftpClient throughout —
SshNetSftpSession is the only path to a remote file — so the vulnerable method
has no caller to reach it from. That makes this a hygiene bump rather than an
incident, and it is worth saying plainly because the alternative on offer was a
scoped NuGetAuditSuppress with that reasoning written beside it. A suppression is
what you reach for when there is nothing to upgrade TO. There is: the maintainers
shipped the fix, taking it costs two nullable annotations, and a suppression
would have left this repository carrying a known-vulnerable library and a comment
explaining why that is fine — which stays true only until somebody adds the first
ScpClient call and has no reason to look here.

BouncyCastle.Cryptography moves 2.6.2 -> 2.7.0 as a consequence, not a
preference. CentralPackageTransitivePinningEnabled makes the PackageVersion here
the resolved version for a transitive dependency, and SSH.NET 2026.0.0 asks for
2.7.0, so a pin left at 2.6.2 is a downgrade error rather than a version this
repository gets to choose. Its comment records the constraint so the next person
does not try to walk it back.

◆ THE RELEASE NOTES SAY NO BREAKING CHANGES. THE NULLABLE ANNOTATIONS DISAGREE.
ConnectionInfo.CurrentServerEncryption is now string? — correct of them, since it
really is null until the key exchange completes — and that is CS8601 at both
places this codebase reads a cipher off a live connection. Neither can observe
the null: SshNetConnection and SshNetSftpSession are only ever constructed after
ConnectAsync has been awaited, which is what their existing remarks already say
and why the reads are at construction rather than lazy.

Coalesced to string.Empty rather than silenced with !, because empty is already
this codebase's word for a cipher that could not be read. MainWindowViewModel's
NullIfEmpty exists for exactly that, TerminalWorkspace.GetSessionFacts already
hands over string.Empty on the same grounds, and the status bar collapses on it.
So the boundary keeps the interface's non-nullable string, no consumer changes,
and an unreachable null would degrade the way a real one already does.

SshNetConnection's remark asserted that SSH.NET types the property non-nullable
and that reading straight through was therefore honest rather than lazy. That
sentence is now false, and a comment contradicting the line beneath it is worse
than no comment, so it says what is actually true: the coalesce is there for the
annotation and the pre-key-exchange window this call site cannot be in.

The Android head needed its own restore. It is outside DodoSSH.slnx, so the
solution-wide --force-evaluate does not reach it, and left alone it would have
kept the vulnerable pin and gone stale under locked mode — the hazard
platform-flags.md records, arriving on schedule.

Heeding the warning left by the ILLink commit: --force-evaluate on Windows
rewrote all 39 lock files with CRLF, and 21 of them had no content change. Only
the 18 that really moved are here.

Verified with `dotnet build` at the root, no audit-disabling flags: 0 errors,
NU1903 gone, and the 21 remaining warnings are the pre-existing Meziantou/CA and
Avalonia ones that were present in the failing build too.

A library upgrade is not a version string, so the suites that exercise it were
run against live containers rather than trusted: 87 Ssh, 80 Terminal, 27
ObjectStore, 10 Transfer, none failing.
2026-08-14 09:36:24 +02:00
jaap-jan fdea3911c1 Name a quick-connect result's vault, and search on it
ci / build and test (pull_request) Failing after 7s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Skipped
ci / android head (pull_request) Failing after 6s
Ctrl+K reaches every vault the session holds a key for — that is deliberate,
and it is what makes the palette worth opening from anywhere. What it did not
do was say where a result came from. A team and a person who both call a
machine prod-db got two identical rows, and Enter took whichever the ranking
happened to put first: the same three characters, two different machines,
depending on nothing anybody could see.

Nothing new is computed for this. HostRowViewModel.VaultBadge has been filled
in since the host list learned to span vaults, and it is already drawn on the
hosts board, the keychain, the snippets list and the known-hosts pane. The
palette was the one list reaching across every vault that did not print it.

◆ THE VAULT IS THE LAST OF THE FOUR RANKS, AND THAT IS THE POINT. A vault name
is the widest reading of the three the palette had: one word matches every host
in that vault at once, where a name or an address matches one machine. So it
sits behind name-starts-with, name-contains and address — otherwise typing a
machine's name would bury it under everybody else's.

It matches on HasVaultBadge rather than on VaultName, so the search only ever
matches what the row actually shows. A session holding one vault prints no
vault on any row, and matching it there would answer "personal" with the entire
keychain, ranked behind nothing and explained by nothing on screen.

In the row, the right-hand column becomes two lines against the two on the
left, in the same order: what this is above, how it is reached below. With one
vault the badge is empty, the line collapses, and the kind word stays centred
exactly where it was. The name is capped and ellipsised because that column is
Auto-sized — a long vault name would otherwise take its width out of the host
name beside it.

Three tests, one per claim. VaultSharingTests puts platform-gateway in the
personal vault and prod-db in "Platform secrets" and types "platform": both come
back, the gateway first. ShellFlowTests types the personal vault's own name into
a one-vault session and gets nothing. QuickConnectTests is the markup's half —
the shared vault's row draws PLATFORM SECRETS, and a one-vault row draws no
vault at all — with the shared vault put into the session through the layout
suite's own StubTeamServer, as the settings pages' suite does it.

449 App tests and 155 layout tests pass. Both suites were run with
-p:NuGetAudit=false: SSH.NET 2025.1.0 has picked up GHSA-q939-rpr3-3284 and
NU1903 fails restore repo-wide, which predates this branch and is nothing to do
with it. No package or lock file is touched here.
2026-08-14 09:35:44 +02:00
jaap-jan 10f80bded1 Merge pull request 'Colour the window's frame, inset Hosts like its neighbours, drop Pins' (#9) from claude/title-bar-color-windows-6d386c into main
ci / build and test (push) Successful in 2m45s
ci / android head (push) Successful in 3m41s
ci / desktop nightly (push) Successful in 1m29s
ci / api image (push) Successful in 31s
Reviewed-on: #9
2026-08-12 11:12:14 +00:00
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 93e35a0095 Stop the SDK's own trimmer version deciding whether CI can restore
ci / build and test (pull_request) Successful in 2m24s
ci / desktop nightly (pull_request) Skipped
ci / android head (pull_request) Successful in 3m22s
ci / api image (pull_request) Successful in 21s
CI went red across the whole repository — main's run 125 and every open pull
request at once — on a restore that never reached a compiler:

    error NU1004: The package reference Microsoft.NET.ILLink.Tasks version has
    changed from [10.0.10, ) to [10.0.11, ). The packages lock file is
    inconsistent with the project dependencies so restore can't be run in
    locked mode.

Nothing in any of those commits touched a package. .NET had shipped SDK 10.0.400.

◆ THE VERSION IN THE LOCK FILES WAS NEVER THIS REPOSITORY'S TO DECIDE.

Microsoft.NET.ILLink.Tasks is referenced by nothing here. The SDK adds it to any
project setting IsTrimmable or IsAotCompatible — DodoSSH.Contracts and
DodoSSH.Crypto do, and the Android head gets it from trimming being on by
default — and it supplies the version itself, from the KnownILLinkPack item in
its own Microsoft.NETCoreSdk.BundledVersions.props. 10.0.302 says 10.0.10;
10.0.400 says 10.0.11.

packages.lock.json records that as a Direct reference with a requested range, so
what the committed file actually means is "whichever SDK last ran a restore".
global.json says rollForward: latestMinor, so setup-dotnet installs the newest
10.x SDK that exists on the morning it runs. The gate did its job — an unreviewed
dependency change is exactly what it is there to stop — but the change it caught
was not one anybody could have reviewed, and it will recur on every servicing
release.

Regenerating the lock files alone would have been the worse repair, and not only
because it holds until the next release. It cannot be done from this machine at
all: every SDK installed here tops out at 10.0.302, which writes 10.0.10 straight
back and re-breaks CI. The recorded version would flip according to who restored
last — the precise state locking exists to prevent.

So the version is pinned in Directory.Build.targets and the three lock files are
regenerated against the pin. It is an Update on the SDK's item rather than a
PackageVersion in Directory.Packages.props because the reference is implicit:
the SDK supplies a version, so central package management is never consulted. It
sits in a target because the conditioning is on %(TargetFramework) — all the
KnownILLinkPack items share one identity and only that metadata separates
net10.0's from net8.0's — and item batching in a condition is legal inside a
target and MSB4191 during evaluation.

Pinned forward to 10.0.11 rather than back to 10.0.10, which would have been a
one-line change with no lock file churn. Holding the trimmer a release behind the
framework it analyses to dodge an error is how a missed trim warning happens, and
taking the newer one makes the bump a reviewed diff, which is what the gate was
asking for.

Verified against the SDK that broke it rather than only the one here:

  - sdk:10.0-alpine, 10.0.400, `dotnet restore DodoSSH.slnx --locked-mode` —
    exit 0. That is ci.yml's line, on CI's SDK.
  - the android workload on sdk:10.0-noble, 10.0.400, locked-mode restore of
    DodoSSH.Client.Android — exit 0. That is scripts/ci-android.sh's line.
  - locally on 10.0.302, the same locked-mode restore of the solution — exit 0.

One set of lock files satisfying both SDKs is the whole point of the pin, and the
third check is the one that demonstrates it.

Release build clean: 0 errors, and 0 IL-prefixed diagnostics from the newer
analyser on the two trimmable projects. 1,869 tests over 19 suites, none failing.

A caution for the next person, learned the hard way here: `--force-evaluate` on
Windows rewrites every lock file it touches with CRLF, and 23 of the 26 had no
content change at all. Only the three that really moved are in this commit.
2026-08-12 11:44:56 +02: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
37 changed files with 1426 additions and 188 deletions
+68
View File
@@ -0,0 +1,68 @@
<Project>
<!--
◆ THE TRIMMER'S VERSION IS PINNED HERE BECAUSE OTHERWISE THE LOCK FILES ARE NOT LOCKED.
Microsoft.NET.ILLink.Tasks is not referenced by anything in this repository. The SDK adds it
on its own to any project that sets IsTrimmable or IsAotCompatible — DodoSSH.Contracts and
DodoSSH.Crypto do, and the Android head gets it from trimming being on by default there — and
the version it asks for is whatever the running SDK happens to bundle. That version lives in
the SDK's own Microsoft.NETCoreSdk.BundledVersions.props, as a KnownILLinkPack item.
Which makes it a dependency whose version is a property of the toolchain rather than of this
repository, and that is the whole problem: packages.lock.json records it as a Direct reference
with a requested range, so the lock file silently means "whichever SDK last ran a restore".
global.json says rollForward: latestMinor, so CI's setup-dotnet installs the newest 10.x SDK
that exists on the day it runs. The moment .NET ships a servicing release, CI's SDK asks for a
version the committed lock files do not have, and the locked-mode restore in ci.yml fails with
NU1004 before a single file is compiled.
That is not hypothetical. It closed the whole pipeline: main's run 125 and every open pull
request went red together, on
error NU1004: The package reference Microsoft.NET.ILLink.Tasks version has changed
from [10.0.10, ) to [10.0.11, ).
with nothing in any of those commits touching a package. .NET had shipped SDK 10.0.400, which
bundles ILLink 10.0.11 where 10.0.302 bundled 10.0.10, and setup-dotnet installed it the next
time anything ran.
Worse than the outage is the shape of the repair without this pin. Regenerating the lock files
holds only until the next servicing release, and it cannot be done from a machine whose newest
SDK is older than the runner's: a restore on 10.0.302 writes 10.0.10 straight back and re-breaks
CI, so the recorded version becomes a fact about whoever ran restore last rather than about this
repository. That is exactly the state locking exists to prevent, and it is not a hypothetical
either — every SDK installed on the machine this pin was written on tops out at 10.0.302.
Pinning it makes the recorded version a decision this repository made, reviewable in a diff
like every other version in Directory.Packages.props, and identical on every machine whatever
SDK it has. Moving it is then a deliberate edit here plus a regenerated lock file, which is the
same ceremony any other dependency bump gets.
It is an Update on the SDK's item rather than a PackageVersion in Directory.Packages.props, and
it has to be: the reference is implicit, so the SDK supplies the version itself and central
package management never gets asked. ProcessFrameworkReferences reads @(KnownILLinkPack) when
it runs, which is why this lives in Directory.Build.targets — the item does not exist yet while
Directory.Build.props is being evaluated.
Keep this within a patch or two of the runtime the SDK ships. It is the trimming analyzer and
the ILLink task, so a small skew is harmless, but a version far behind the framework being
analysed is a real way to miss a trim warning.
-->
<Target Name="PinTheILLinkPackVersion" BeforeTargets="ProcessFrameworkReferences">
<!--
Inside a target, and not for tidiness. The SDK ships one KnownILLinkPack per target framework
and they all share the identity "Microsoft.NET.ILLink.Tasks", so the TargetFramework metadata
is the only thing telling net10.0's entry from net8.0's. A condition on %(...) is item
batching, which MSBuild permits in a target and rejects during evaluation with MSB4191 — so
an ItemGroup at the top of this file cannot express "only the net10.0 one" at all, and the
unconditioned Update it would have to become rewrites every framework's entry.
-->
<ItemGroup>
<KnownILLinkPack Update="Microsoft.NET.ILLink.Tasks"
Condition="'%(TargetFramework)' == 'net10.0'"
ILLinkPackVersion="10.0.11" />
</ItemGroup>
</Target>
</Project>
+28 -3
View File
@@ -88,8 +88,12 @@
-->
<PackageVersion Include="NSec.Cryptography" Version="26.4.0" />
<PackageVersion Include="libsodium" Version="1.0.22" />
<!-- Managed differential oracle for the crypto test suite only. -->
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.6.2" />
<!--
Managed differential oracle for the crypto test suite only. Also what SSH.NET pulls in,
so central transitive pinning makes this pin its floor too: 2.7.0 is what SSH.NET
2026.0.0 asks for, and pinning below that is a downgrade error rather than a preference.
-->
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.7.0" />
</ItemGroup>
<ItemGroup Label="Desktop client">
<!--
@@ -98,8 +102,14 @@
agent forwarding (needs an upstream change; de-scoped from v1) and being handed a
pre-connected Stream — it performs its own socket connect, which is why the relay and
ProxyJump both go through a loopback TCP bridge. See docs/adr/.
2026.0.0 is the fix for GHSA-q939-rpr3-3284, a path traversal in ScpClient.Download's
recursive mode that trusts server-supplied names (everything up to and including
2025.1.0 is affected). Nothing here uses ScpClient — transfers go through SftpClient —
so this was an upgrade on principle rather than an exposure, and the release notes
list no breaking changes over 2025.1.0.
-->
<PackageVersion Include="SSH.NET" Version="2025.1.0" />
<PackageVersion Include="SSH.NET" Version="2026.0.0" />
<!--
The S3 client, for buckets as a remote in the file browser. First-party, Apache-2.0, and
managed only — no native assets — which is the bar this file sets for anything that gets
@@ -211,5 +221,20 @@
mistakes that cause real authorization holes.
-->
<PackageVersion Include="WireMock.Net" Version="2.13.0" />
<!--
A JavaScript interpreter, in managed code, so that terminal.js can be tested as the file that
ships rather than as a transcription of it into C#. Test-only and referenced by exactly one
project; nothing in src depends on it.
Chosen over shelling out to node, which is the obvious alternative and needs node present
wherever the suite runs — CI installs one already, but a test that only runs when a separate
command is remembered is a test that stops being run. Chosen over a headless browser for the
same reason several times over.
What it does not buy: Jint is not Chromium, so this proves the page's own logic and nothing
about how WebView2 or Android's WebView behave. That line is drawn in RendererPage's remark and
picked up by docs/manual-checks.md 1.10 and 11.12a.
-->
<PackageVersion Include="Jint" Version="4.16.0" />
</ItemGroup>
</Project>
+52 -1
View File
@@ -171,6 +171,32 @@ bound to the same side of `IsImportOpen`. SettingsNav lighting a different row w
over the importer would be a guard added to `OnKeyDown` for `IsSettingsMode` that the design never asked for
and this application's own quick-connect card was built to reach past.
### 1.10 A terminal left alone for a long time is still a terminal · **needs an hour, or a debugger**
Open a shell, leave the terminal for another screen — HOSTS, FILES, anything — and leave the application
alone for long enough that the window has been in the background for the better part of an hour. Locking the
machine or letting it sleep counts and is the easier way to get there. Come back and click the session's
tab.
**Pass:** the pane is exactly where it was and takes input straight away. If anything is shown at all it is
`Reconnecting the terminal view…` for a moment, in the second or so before the socket is back — never a
status that is still there after that.
**Both of the page's own rules here are covered by `RendererReconnectionTests`**, which runs `terminal.js`
itself in a fake browser — so a failure of this check is more likely to be the WebView behaving unlike that
fake than the page's logic being wrong. That is exactly the division: the test owns the logic, this owns the
platform.
**Failure means:** a banner that stays up is the page's retry not running. It is a `setTimeout` chain, and a
chain is what a WebView is entitled to throttle or freeze while nobody is looking at the page; `terminal.js`
answers that with wake-ups on `visibilitychange`, `focus` and `online`, none of which can be throttled,
plus a watchdog for a handshake that never finishes. A banner that flickers on and off every second or two
instead is the opposite fault — a reconnect loop, in which each attempt displaces the socket before it
through `TerminalDataPlane.UpgradeAsync`'s takeover and the displaced socket's close schedules the next.
That is what the "is this still the page's socket" guard in `connect()` exists to stop. A pane that takes no
input while the banner is *clear* is neither: the socket is open and dead, which nothing on this page can
currently see — see the note at the end of 11.12a.
---
## Phase 2 — Known Hosts as its own page
@@ -1797,6 +1823,29 @@ worse than saying nothing: the banner exists so this is never silently wrong. If
banner is there, the session's credit window was not reset on reattach and the shell is frozen behind it —
see `TerminalWorkspace.ReplayAfterAttachAsync`.
### 11.12a Coming back to a terminal screen left alone for a long while
The same shape as 11.12 and a different trigger: rather than backgrounding the app, stay in it. With a shell
open, leave the terminal for HOSTS, FILES or MORE — which collapses the renderer to GONE, so the page is
hidden by Chromium's reckoning — and leave the phone alone for at least ten minutes with the screen off.
Then come back to the app and to the terminal.
**Pass:** the pane is there and takes input at once, or reconnects visibly within about a second of the
screen appearing. `Reconnecting the terminal view…` on the way in is fine; still being there once the
terminal has been on screen for a couple of seconds is not.
**Failure means:** the page's retry did not survive being hidden. A hidden WebView has its timers throttled
— once a minute after five minutes hidden — and a renderer that was frozen or reclaimed runs none of them,
which is why `terminal.js` does not rely on the timer alone: `visibilitychange` is the event that says the
screen is back, and it reconnects immediately rather than waiting to be asked twice. Ten minutes is chosen
to clear the five-minute threshold with room to spare.
**Not covered by either check, and worth knowing:** a socket that is *open and dead* — the connection gone
without either end noticing, which a suspended renderer can leave behind — shows no banner at all, because
`readyState` still reads OPEN and nothing on this page probes further. The symptom is a terminal that looks
connected and swallows what is typed. If that is ever seen, it is a different bug from this one and needs a
liveness probe rather than a faster retry.
---
## Phase 12 — Shared vaults: the operations that span two accounts
@@ -2424,7 +2473,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**
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
— the terminal **reflows cleanly rather than being sliced**, with the remote seeing the smaller row count.
@@ -984,7 +984,7 @@
"type": "Project",
"dependencies": {
"NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )"
"SSH.NET": "[2026.0.0, )"
}
},
"dodossh.client.storage": {
@@ -1044,9 +1044,9 @@
},
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
"requested": "[2.7.0, )",
"resolved": "2.7.0",
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
},
"EFCore.NamingConventions": {
"type": "CentralTransitive",
@@ -1140,11 +1140,11 @@
},
"SSH.NET": {
"type": "CentralTransitive",
"requested": "[2025.1.0, )",
"resolved": "2025.1.0",
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
"requested": "[2026.0.0, )",
"resolved": "2026.0.0",
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
"dependencies": {
"BouncyCastle.Cryptography": "2.6.2",
"BouncyCastle.Cryptography": "2.7.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
}
@@ -87,12 +87,27 @@
TextTrimming="CharacterEllipsis" />
</StackPanel>
<!--
Two readings stacked against the two on the left, and in the same order: what this row
IS above, how it is reached below.
◆ THE VAULT, and only when there is more than one to be in — HostRowViewModel.VaultBadge
is empty otherwise, which collapses the line and leaves the kind word centred as it was.
The palette searches every readable vault at once, so without it two hosts a team and a
person both call "prod-db" are two identical rows, and Enter takes whichever the ranking
happened to put first. Trimmed against a cap because the column is Auto-sized: a long
vault name would otherwise take its width out of the host name beside it.
credential / key / password — never the mock's SSH/SFTP kind column. Every palette
connect here is SSH, so printing that word would be a constant dressed up as a reading;
see hosts-v5-design-spec.md's deviations and HostRowViewModel.Authentication.
-->
<TextBlock Grid.Column="2" Classes="mono qcrow-subtext" Text="{Binding Authentication}"
FontSize="11" Margin="10,0,0,0" VerticalAlignment="Center" />
<StackPanel Grid.Column="2" Spacing="1" Margin="10,0,0,0" VerticalAlignment="Center">
<TextBlock Classes="mono qcrow-subtext" Text="{Binding VaultBadge}" FontSize="10"
HorizontalAlignment="Right" MaxWidth="150"
TextTrimming="CharacterEllipsis" IsVisible="{Binding HasVaultBadge}" />
<TextBlock Classes="mono qcrow-subtext" Text="{Binding Authentication}"
FontSize="11" HorizontalAlignment="Right" />
</StackPanel>
</Grid>
</Border>
</DataTemplate>
+8 -8
View File
@@ -405,7 +405,7 @@
"type": "Project",
"dependencies": {
"NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )"
"SSH.NET": "[2026.0.0, )"
}
},
"dodossh.client.storage": {
@@ -465,9 +465,9 @@
},
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
"requested": "[2.7.0, )",
"resolved": "2.7.0",
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
},
"EFCore.NamingConventions": {
"type": "CentralTransitive",
@@ -567,11 +567,11 @@
},
"SSH.NET": {
"type": "CentralTransitive",
"requested": "[2025.1.0, )",
"resolved": "2025.1.0",
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
"requested": "[2026.0.0, )",
"resolved": "2026.0.0",
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
"dependencies": {
"BouncyCastle.Cryptography": "2.6.2",
"BouncyCastle.Cryptography": "2.7.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
}
@@ -55,14 +55,14 @@
"type": "Project",
"dependencies": {
"NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )"
"SSH.NET": "[2026.0.0, )"
}
},
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
"requested": "[2.7.0, )",
"resolved": "2.7.0",
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
},
"libsodium": {
"type": "CentralTransitive",
@@ -81,11 +81,11 @@
},
"SSH.NET": {
"type": "CentralTransitive",
"requested": "[2025.1.0, )",
"resolved": "2025.1.0",
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
"requested": "[2026.0.0, )",
"resolved": "2026.0.0",
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
"dependencies": {
"BouncyCastle.Cryptography": "2.6.2",
"BouncyCastle.Cryptography": "2.7.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
}
@@ -148,7 +148,7 @@
"type": "Project",
"dependencies": {
"NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )"
"SSH.NET": "[2026.0.0, )"
}
},
"dodossh.client.storage": {
@@ -187,9 +187,9 @@
},
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
"requested": "[2.7.0, )",
"resolved": "2.7.0",
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
},
"EFCore.NamingConventions": {
"type": "CentralTransitive",
@@ -289,11 +289,11 @@
},
"SSH.NET": {
"type": "CentralTransitive",
"requested": "[2025.1.0, )",
"resolved": "2025.1.0",
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
"requested": "[2026.0.0, )",
"resolved": "2026.0.0",
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
"dependencies": {
"BouncyCastle.Cryptography": "2.6.2",
"BouncyCastle.Cryptography": "2.7.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
}
@@ -2141,8 +2141,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// <remarks>
/// Ranked rather than merely filtered: a host whose name starts with what was typed comes before one
/// that merely contains it, and both come before a match found only in the address. Typing three
/// characters of a name people use daily should not put that host third.
/// that merely contains it, both come before a match found only in the address, and all three come
/// before one found only in the vault's name. Typing three characters of a name people use daily should
/// not put that host third. See <see cref="Rank"/>.
/// </remarks>
private void RefreshSearchResults()
{
@@ -2175,6 +2176,21 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
OnPropertyChanged(nameof(HasSearchResults));
}
/// <summary>Where one host places against what was typed, or <see cref="int.MaxValue"/> for no match.</summary>
/// <remarks>
/// <para>
/// <b>The vault comes last, and only when the row prints it.</b> Its name is the widest of the three
/// readings — one word can match every host in a team's vault at once — so a host whose own name or
/// address answers the query has to come first, or typing a machine's name would bury it under everybody
/// else's.
/// </para>
/// <para>
/// <see cref="HostRowViewModel.HasVaultBadge"/> rather than the name itself, so this matches exactly what
/// the row shows: a session holding one vault prints no vault on any row, and matching a name nothing
/// displays would answer a query with a list whose reason for existing is invisible — type "personal"
/// there and every host comes back for no reason anybody can see.
/// </para>
/// </remarks>
private static int Rank(HostRowViewModel host, string query)
{
if (host.Label.StartsWith(query, StringComparison.CurrentCultureIgnoreCase))
@@ -2187,8 +2203,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
return 1;
}
return host.Address.Contains(query, StringComparison.CurrentCultureIgnoreCase)
? 2
if (host.Address.Contains(query, StringComparison.CurrentCultureIgnoreCase))
{
return 2;
}
return host.HasVaultBadge
&& host.VaultName.Contains(query, StringComparison.CurrentCultureIgnoreCase)
? 3
: int.MaxValue;
}
@@ -74,16 +74,6 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
/// </remarks>
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 ClientSettingsStore settings;
private readonly TimeProvider clock;
@@ -242,16 +232,40 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
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)
{
try
{
await Task.Delay(FirstCheckDelay, clock, cancellationToken).ConfigureAwait(true);
await Task.Yield();
using var timer = new PeriodicTimer(CheckInterval, clock);
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);
}
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true));
+247 -21
View File
@@ -16,7 +16,8 @@
happened to straddle a frame boundary, which shows up as occasional mojibake in exactly the
conditions that are hardest to reproduce.
3. The socket reconnects itself, forever, with backoff. This page's WebView is routinely killed
3. The socket reconnects itself, forever, with backoff and on being looked at again, which is not
the same thing and is the half a timer cannot cover. This page's WebView is routinely killed
and reloaded by Android under memory pressure or simply for being backgrounded, so "the
socket closed" is an ordinary event here, not the end of the terminal's life see connect().
A reloaded page starts with an empty session map, so createSession is idempotent (a session
@@ -52,6 +53,22 @@ const SCROLLBACK_LINES = 5000;
const RECONNECT_INITIAL_DELAY_MS = 1000;
const RECONNECT_MAX_DELAY_MS = 5000;
/*
How long a socket is given to finish its handshake before it is treated as a failure.
A socket that cannot connect normally says so and says it quickly 'error' then 'close', within a
millisecond or two of a loopback refusal. The case this covers is the one that says nothing: an attempt
parked in CONNECTING with no event ever coming, which is what a WebSocket opened by a renderer that is
then suspended, or one whose handshake the host never answers, leaves this page holding.
Without the watchdog that state is terminal, and quietly so. Every retry in this file is scheduled by a
close or an error, so an attempt that produces neither schedules nothing: the banner says the view is
reconnecting for the rest of the page's life while nothing whatever is reconnecting. Five seconds is
generous against a handshake that ordinarily takes about a millisecond, and short against somebody
waiting on their terminal to come back.
*/
const HANDSHAKE_TIMEOUT_MS = 5000;
/*
Styled like the SESSION_CLOSED banner (matching \x1b[38;5;244, the same dim grey), but written by
createSession's caller rather than by createSession itself: only a *replay* landing on a pane that
@@ -84,14 +101,57 @@ const RELEASE_FOCUS_MESSAGE = 'dodossh.release-focus';
const root = document.getElementById('root');
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();
/** @type {WebSocket | null} */
let socket = null;
function setStatus(text) {
statusBanner.textContent = text ?? '';
/** Whose pane is showing, or null before there is one — see activate(). */
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. */
@@ -292,7 +352,7 @@ function createSession(sessionId) {
term.onResize(() => sendResize(sessionId, term, pane));
const session = { term, fit, pane };
const session = { term, fit, pane, notice: '' };
sessions.set(sessionId, session);
activate(sessionId);
@@ -306,6 +366,11 @@ function activate(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);
if (active) {
active.term.focus();
@@ -373,7 +438,10 @@ function handleFrame(buffer) {
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;
}
@@ -426,7 +494,14 @@ function handleFrame(buffer) {
session.pane.remove();
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;
}
@@ -503,14 +578,19 @@ function handleFrame(buffer) {
const session = sessions.get(sessionId);
const reason = new TextDecoder().decode(payload);
if (session) {
// 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;
if (!session) {
// No pane, so there is nothing this page can honestly hang the reason on. It used to go into
// the banner anyway, which printed one session's ending underneath whichever pane happened to
// be showing at the time.
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;
}
@@ -520,8 +600,47 @@ function handleFrame(buffer) {
}
}
/*
THE SOCKET COMES BACK BY ITSELF, INCLUDING FOR A PAGE NOBODY HAS LOOKED AT IN AN HOUR
Retrying on a timer is the easy half and was the whole of this. The three rules below are what make the
retry actually reach a page that has been sitting collapsed each of them a way this page was found
showing "Reconnecting the terminal view…" over a socket that nothing was reconnecting.
1. ONLY THE CURRENT ATTEMPT'S EVENTS COUNT. connect() abandons whatever socket was attached before it,
and an abandoned socket still reports its end: the host aborts it the moment the newcomer upgrades,
which is TerminalDataPlane.UpgradeAsync's takeover doing exactly what it is meant to. Counting that
close as a fresh failure schedules a retry against a socket that has just succeeded, and the
takeover then aborts *that* one, whose close schedules the next a loop with no fixed point, in
which the terminal reconnects every second or so forever and the banner is up for most of it. Every
handler below asks whether it is still the page's socket before it does anything.
2. AN ATTEMPT THAT NEVER FINISHES IS A FAILURE TOO. See HANDSHAKE_TIMEOUT_MS.
3. COMING BACK IS A REASON TO TRY, NOT ONLY THE CLOCK. The retry is a setTimeout chain, and a chain is
precisely what a WebView is entitled to stop running. Chromium throttles timers in a page nobody is
looking at down to once a minute once it has been hidden five minutes and a renderer that is
frozen, or reclaimed and not yet reloaded, runs none of them at all. So the socket drops while nobody
is watching, the banner goes up, the retry is scheduled, and the retry is then the one thing not
running: coming back shows a terminal that says it is reconnecting and, for as long as that lasts,
is not.
The phone is where this is easiest to reach, because leaving the terminal for another screen
collapses its WebView to GONE see createSession's note on the GPU context and a WebView with no
surface is a page the platform may treat as hidden. Which of the two mechanisms actually bit is not
established here, and does not need to be: both end with a timer that will not run, and the fix is
not to guess at either but to stop depending on the timer alone.
Hence the wake-ups at the bottom of this file. What they add is not a faster retry; it is a retry
driven by the one thing that cannot be throttled, which is the user arriving.
*/
/** @type {number | null} */
let reconnectTimer = null;
/** @type {number | null} */
let handshakeTimer = null;
let reconnectDelay = RECONNECT_INITIAL_DELAY_MS;
/**
@@ -535,7 +654,7 @@ function scheduleReconnect() {
return;
}
setStatus('Reconnecting the terminal view…');
setTransportStatus('Reconnecting the terminal view…');
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
@@ -545,31 +664,112 @@ function scheduleReconnect() {
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_DELAY_MS);
}
/**
* Tries the socket again now, if it is down.
*
* The entry point for "somebody is looking at this page again", and it has to be safe to call as often
* as that happens which on the desktop is every time the window is clicked. A socket that is up makes
* this nothing at all.
*
* An attempt still in CONNECTING is left alone rather than restarted: it may be about to succeed, and
* the one that is not is already the handshake watchdog's to give up on.
*/
function reconnectIfDown() {
if (socket !== null
&& (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
return;
}
// Whatever the timer was going to do, this is doing now. Left pending it would land on top of the
// socket this call is about to open, and the takeover that followed is the loop rule 1 describes.
if (reconnectTimer !== null) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
// Back to the quick attempt. The wait grew to space out retries during an outage nobody was watching,
// and being called at all means somebody is watching now.
reconnectDelay = RECONNECT_INITIAL_DELAY_MS;
connect();
}
function connect() {
const token = root.dataset.token;
const url = root.dataset.socket;
/*
Whatever was attached is abandoned here, and closed rather than dropped: the host takes the socket
over regardless, but one left open is a connection it has to abort and an event this page then has to
ignore. Cleared out of `socket` before the close, so that every handler including that close,
whenever it lands can already tell the attempt is no longer the page's.
*/
const abandoned = socket;
socket = null;
abandoned?.close();
// The token travels as a subprotocol rather than a query parameter, which keeps it out of
// anything that logs URLs.
socket = new WebSocket(url, ['dodossh.terminal.v1', `token.${token}`]);
socket.binaryType = 'arraybuffer';
const pending = new WebSocket(url, ['dodossh.terminal.v1', `token.${token}`]);
pending.binaryType = 'arraybuffer';
socket = pending;
socket.addEventListener('open', () => {
setStatus('');
/** Whether this attempt is still the page's, rather than one a later connect() has replaced. */
const isCurrent = () => socket === pending;
const forgetHandshakeTimer = () => {
if (handshakeTimer !== null) {
clearTimeout(handshakeTimer);
handshakeTimer = null;
}
};
forgetHandshakeTimer();
handshakeTimer = setTimeout(() => {
handshakeTimer = null;
// Still CONNECTING with nothing on its way. Only the retry is arranged here; abandoning the attempt
// is left to the connect() that retry runs, which is the one place a socket is replaced.
if (isCurrent() && pending.readyState === WebSocket.CONNECTING) {
scheduleReconnect();
}
}, HANDSHAKE_TIMEOUT_MS);
pending.addEventListener('open', () => {
// A replaced attempt cannot reach here — connect() closes what it abandons, and a socket closed
// while connecting never opens — so this is a guard against the ordering rather than a live case.
if (!isCurrent()) {
return;
}
forgetHandshakeTimer();
setTransportStatus('');
// 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.
reconnectDelay = RECONNECT_INITIAL_DELAY_MS;
});
socket.addEventListener('message', (event) => handleFrame(event.data));
pending.addEventListener('message', (event) => {
if (isCurrent()) {
handleFrame(event.data);
}
});
// Both close and error retry. They are not the same event on every failure — a socket that never
// opens can fire only 'error', one that opens and later drops fires only 'close' — and the host
// side of this same problem (TerminalDataPlane.UpgradeAsync's takeover) is exactly why retrying is
// safe: whichever attempt eventually reaches the host, a fresh valid upgrade always wins the socket.
socket.addEventListener('close', scheduleReconnect);
socket.addEventListener('error', scheduleReconnect);
const retry = () => {
if (isCurrent()) {
forgetHandshakeTimer();
scheduleReconnect();
}
};
pending.addEventListener('close', retry);
pending.addEventListener('error', retry);
}
// One observer for the whole root rather than one per pane: resizes arrive in bursts while a
@@ -582,4 +782,30 @@ new ResizeObserver(() => {
window.addEventListener('beforeunload', () => socket?.close());
/*
The ways this page finds out somebody is looking at it again rule 3 at the top of the transport
section. All three end in the same check, and that check is what makes them safe to be as noisy as they
are: with a healthy socket every one of them does nothing.
'visibilitychange' is the phone's. A WebView collapsed to GONE is a hidden page, so coming back to the
terminal screen is the event that says so, and it is the same event Chromium lifts its own throttling
on this page simply does not wait to be asked twice.
'focus' is the desktop's, where the page is *not* marked hidden while the WebView is collapsed
(measured; see docs/platform-flags.md) but a window left in the background for hours has had its timers
throttled all the same. It fires when the terminal takes the keyboard back, which is the same gesture.
'online' is neither head's ordinary case, because the socket is loopback and has nothing to do with the
network but the event does follow a machine coming back from sleep, and a page with a dead socket has
no reason to ignore any hint that the world has moved.
*/
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
reconnectIfDown();
}
});
window.addEventListener('focus', reconnectIfDown);
window.addEventListener('online', reconnectIfDown);
connect();
+8 -8
View File
@@ -207,7 +207,7 @@
"type": "Project",
"dependencies": {
"NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )"
"SSH.NET": "[2026.0.0, )"
}
},
"dodossh.client.storage": {
@@ -267,9 +267,9 @@
},
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
"requested": "[2.7.0, )",
"resolved": "2.7.0",
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
},
"EFCore.NamingConventions": {
"type": "CentralTransitive",
@@ -369,11 +369,11 @@
},
"SSH.NET": {
"type": "CentralTransitive",
"requested": "[2025.1.0, )",
"resolved": "2025.1.0",
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
"requested": "[2026.0.0, )",
"resolved": "2026.0.0",
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
"dependencies": {
"BouncyCastle.Cryptography": "2.6.2",
"BouncyCastle.Cryptography": "2.7.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
}
@@ -304,10 +304,11 @@ internal sealed class SshNetConnection(SshClient client, HostKeyPresentation hos
/// Read at construction rather than lazily: by the time an <see cref="SshNetConnection"/> exists,
/// <see cref="SshNetConnectionFactory.ConnectAsync"/> has already awaited <c>client.ConnectAsync</c>, so
/// <c>ConnectionInfo</c> is already populated and there is no earlier moment reading it would race. SSH.NET
/// types the property as a non-nullable <c>string</c>, so this reads straight through rather than coalescing
/// a null that the library's own contract says cannot occur.
/// types the property as nullable all the same — it is null before the key exchange completes — so the
/// coalesce is here to satisfy that annotation, not because this call site expects to see it. Empty is the
/// value this codebase already uses for a cipher that could not be read; see <c>MainWindowViewModel.NullIfEmpty</c>.
/// </remarks>
public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption;
public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption ?? string.Empty;
/// <inheritdoc />
public Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken)
+3 -2
View File
@@ -24,9 +24,10 @@ internal sealed class SshNetSftpSession(SftpClient client, HostKeyPresentation h
/// <remarks>
/// Read at construction, the same way and for the same reason as <c>SshNetConnection.Cipher</c>: this type
/// is only ever built after <c>SshNetConnectionFactory.OpenSftpAsync</c> has awaited <c>client.ConnectAsync</c>,
/// so <c>ConnectionInfo</c> is already populated by the time there is a session to read it from.
/// so <c>ConnectionInfo</c> is already populated by the time there is a session to read it from. The coalesce
/// is there for SSH.NET's nullable annotation, which covers the pre-key-exchange window this never sees.
/// </remarks>
public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption;
public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption ?? string.Empty;
/// <inheritdoc />
public string HomeDirectory { get; } = homeDirectory;
+7 -7
View File
@@ -31,11 +31,11 @@
},
"SSH.NET": {
"type": "Direct",
"requested": "[2025.1.0, )",
"resolved": "2025.1.0",
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
"requested": "[2026.0.0, )",
"resolved": "2026.0.0",
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
"dependencies": {
"BouncyCastle.Cryptography": "2.6.2",
"BouncyCastle.Cryptography": "2.7.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
},
@@ -54,9 +54,9 @@
},
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
"requested": "[2.7.0, )",
"resolved": "2.7.0",
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
},
"libsodium": {
"type": "CentralTransitive",
@@ -37,14 +37,14 @@
"type": "Project",
"dependencies": {
"NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )"
"SSH.NET": "[2026.0.0, )"
}
},
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
"requested": "[2.7.0, )",
"resolved": "2.7.0",
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
},
"libsodium": {
"type": "CentralTransitive",
@@ -63,11 +63,11 @@
},
"SSH.NET": {
"type": "CentralTransitive",
"requested": "[2025.1.0, )",
"resolved": "2025.1.0",
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
"requested": "[2026.0.0, )",
"resolved": "2026.0.0",
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
"dependencies": {
"BouncyCastle.Cryptography": "2.6.2",
"BouncyCastle.Cryptography": "2.7.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
}
@@ -37,14 +37,14 @@
"type": "Project",
"dependencies": {
"NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )"
"SSH.NET": "[2026.0.0, )"
}
},
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
"requested": "[2.7.0, )",
"resolved": "2.7.0",
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
},
"libsodium": {
"type": "CentralTransitive",
@@ -63,11 +63,11 @@
},
"SSH.NET": {
"type": "CentralTransitive",
"requested": "[2025.1.0, )",
"resolved": "2025.1.0",
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
"requested": "[2026.0.0, )",
"resolved": "2026.0.0",
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
"dependencies": {
"BouncyCastle.Cryptography": "2.6.2",
"BouncyCastle.Cryptography": "2.7.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
}
+7 -7
View File
@@ -1744,9 +1744,9 @@
},
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
"requested": "[2.7.0, )",
"resolved": "2.7.0",
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
},
"EFCore.NamingConventions": {
"type": "CentralTransitive",
@@ -1847,11 +1847,11 @@
},
"SSH.NET": {
"type": "CentralTransitive",
"requested": "[2025.1.0, )",
"resolved": "2025.1.0",
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
"requested": "[2026.0.0, )",
"resolved": "2026.0.0",
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
"dependencies": {
"BouncyCastle.Cryptography": "2.6.2",
"BouncyCastle.Cryptography": "2.7.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
},
@@ -236,6 +236,51 @@ public sealed class QuickConnectTests : IAsyncLifetime
});
}
/// <remarks>
/// The palette searches every vault the session holds a key for, so a row has to say which one it came
/// out of. Two machines a team and a person both call <c>prod-db</c> are otherwise two identical rows,
/// and Enter takes whichever the ranking happened to put first.
/// <para>
/// Typed into rather than read off the unfiltered list, because the shared vault's host sorts last — the
/// active vault's rows come first — and the list virtualises, so the row this is about might never be
/// realised. Narrowing to it also proves the search reaches past the active vault at all.
/// </para>
/// </remarks>
[Fact]
public async Task AResultSaysWhichVaultItCameOutOf()
{
await SeedSharedHostAsync();
await OnThePaletteAsync((palette, window) =>
{
shell.SearchText = "prod-db";
Relayout(window);
var found = shell.SearchResults.ShouldHaveSingleItem();
found.VaultId.ShouldNotBe(session.ActiveVaultId, "the palette reaches past the active vault");
VisibleTexts(RowFor(palette, found)).ShouldContain("PLATFORM SECRETS", StringComparer.Ordinal);
});
}
/// <remarks>
/// The other half of the rule, and the reason the name is a badge rather than a column: a vault named on
/// every row of a session that has only one is the same fact repeated, which is noise rather than a
/// reading. <c>HostRowViewModel.VaultBadge</c> is empty there, and an empty line has to collapse rather
/// than leave a gap above the kind word.
/// </remarks>
[Fact]
public async Task AResultNamesNoVaultWhenThereIsOnlyOneToBeIn()
{
await OnThePaletteAsync((palette, _) =>
{
var first = shell.SearchResults[0];
first.HasVaultBadge.ShouldBeFalse("this fixture's session holds the personal vault alone");
VisibleTexts(RowFor(palette, first)).ShouldNotContain("PERSONAL", StringComparer.Ordinal);
});
}
/// <remarks>
/// The palette is a box somebody is expected to start typing into, and for a while it was not: the window
/// focused it from the view model's <c>PropertyChanged</c>, which runs before the binding that reveals the
@@ -307,6 +352,25 @@ public sealed class QuickConnectTests : IAsyncLifetime
.OfType<ListBoxItem>()
.First(item => ReferenceEquals(item.DataContext, host));
/// <summary>What one row actually draws, in order, ignoring the lines that collapsed.</summary>
private static List<string> VisibleTexts(Visual row) =>
row.GetVisualDescendants()
.OfType<TextBlock>()
.Where(text => text.IsEffectivelyVisible)
.Select(text => text.Text ?? string.Empty)
.ToList();
/// <summary>Runs the layout pass the application's dispatcher would run after the list changed.</summary>
/// <remarks>
/// Without it the new rows are in the collection but not in the visual tree, so <see cref="RowFor"/>
/// finds nothing to look at.
/// </remarks>
private static void Relayout(Window window)
{
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
}
private static Point Centre(Visual control, Visual window) =>
control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window)
?? throw new InvalidOperationException("the control is not in this window's tree");
@@ -325,4 +389,37 @@ public sealed class QuickConnectTests : IAsyncLifetime
await vault.LoadAsync(Token);
}
/// <summary>
/// Adds a second, shared vault to the fixture's session and files one host into it.
/// </summary>
/// <remarks>
/// Straight into the session rather than through <c>VaultsViewModel</c>, which is the technique the
/// settings pages' suite uses and for the same reason: creating a vault needs a connection, and this
/// shell has none. The key is generated on this machine either way, so what the session ends up holding
/// is the same thing a real creation leaves behind — see <see cref="StubTeamServer"/>.
/// </remarks>
private async Task SeedSharedHostAsync()
{
using var teamServer = new StubTeamServer();
var shared = await session.CreateTeamVaultAsync(
teamServer.Teams, StubTeamServer.SharedTeamId, "Platform secrets", Token);
await vault.LoadAsync(Token);
vault.SelectedTargetVault =
vault.TargetVaults.Single(choice => choice.VaultId == shared.VaultId);
vault.NewHostCommand.Execute(null);
vault.EditorLabel = "prod-db";
vault.EditorHostname = "db.internal";
vault.EditorUsername = "deploy";
await vault.SaveHostCommand.ExecuteAsync(null);
vault.IsEditing.ShouldBeFalse(vault.Status);
await vault.LoadAsync(Token);
}
}
@@ -551,7 +551,7 @@
"type": "Project",
"dependencies": {
"NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )"
"SSH.NET": "[2026.0.0, )"
}
},
"dodossh.client.storage": {
@@ -663,9 +663,9 @@
},
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
"requested": "[2.7.0, )",
"resolved": "2.7.0",
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
},
"CommunityToolkit.Mvvm": {
"type": "CentralTransitive",
@@ -771,11 +771,11 @@
},
"SSH.NET": {
"type": "CentralTransitive",
"requested": "[2025.1.0, )",
"resolved": "2025.1.0",
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
"requested": "[2026.0.0, )",
"resolved": "2026.0.0",
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
"dependencies": {
"BouncyCastle.Cryptography": "2.6.2",
"BouncyCastle.Cryptography": "2.7.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
},
@@ -1765,6 +1765,28 @@ public sealed class ShellFlowTests : IAsyncLifetime
shell.IsTerminalShowing.ShouldBeTrue();
}
/// <remarks>
/// The palette searches a host's vault name, and only where the row prints it — which a session holding
/// one vault never does. Matching it anyway would answer "personal" with the entire keychain, ranked
/// behind nothing and explained by nothing on screen. See <c>MainWindowViewModel.Rank</c>.
/// </remarks>
[Fact]
public async Task ThePalette_DoesNotMatchTheVaultNameWhenThereIsOnlyOneVaultToBeIn()
{
var vault = await ReadyToConnectAsync();
vault.VaultName.ShouldBe("Personal", "which is the word this test types");
shell.ToggleSearchCommand.Execute(null);
shell.SearchResults.ShouldHaveSingleItem()
.HasVaultBadge.ShouldBeFalse("one vault is nothing to tell apart");
shell.SearchText = "personal";
shell.SearchResults.ShouldBeEmpty();
}
/// <remarks>
/// The rail marks where you are, and a terminal is not one of its destinations. Lighting HOSTS while a
/// terminal fills the window would point at a screen that is not showing — and the selected tab already
@@ -403,6 +403,46 @@ public sealed class UpdateFlowTests : IDisposable
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]
public async Task DisposingStopsTheLoop()
{
@@ -766,6 +766,59 @@ public sealed class VaultSharingTests : IAsyncLifetime
vault.Status.ShouldContain("left alone", Case.Insensitive);
}
/// <remarks>
/// <para>
/// The palette reaches every vault the session holds a key for, so the vault's name is one of the things
/// somebody can reasonably type into it: "show me the team's machines" is a question a list spanning two
/// vaults invites, and until now the only answer was to know one of the machines by name already.
/// </para>
/// <para>
/// The order is the assertion that matters. A vault name is the widest of the three readings — one word
/// matches every host in that vault at once — so the host whose own name carries the word has to come
/// first, or typing a machine's name would bury it under everybody else's.
/// </para>
/// </remarks>
[Fact]
public async Task ThePalette_FindsAHostByItsVaultsName_BehindOneCalledThatItself()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
vault.NewHostCommand.Execute(null);
vault.EditorLabel = "platform-gateway";
vault.EditorHostname = "gateway.internal";
await vault.SaveHostCommand.ExecuteAsync(null);
vault.NewHostCommand.Execute(null);
vault.EditorSelectedVault =
vault.EditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
vault.EditorLabel = "prod-db";
vault.EditorHostname = "db.internal";
await vault.SaveHostCommand.ExecuteAsync(null);
vault.IsEditing.ShouldBeFalse(vault.Status);
shell.ToggleSearchCommand.Execute(null);
shell.SearchText = "platform";
shell.SearchResults
.Select(row => row.Label)
.ShouldBe(["platform-gateway", "prod-db"], "a host's own name outranks its vault's");
shell.SearchResults[1].VaultBadge.ShouldBe(
"PLATFORM SECRETS", "and the row says what it was found by");
}
/// <summary>
/// Two hosts for the run above: one in the personal vault under a group, one already in the destination.
/// </summary>
@@ -540,7 +540,7 @@
"type": "Project",
"dependencies": {
"NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )"
"SSH.NET": "[2026.0.0, )"
}
},
"dodossh.client.storage": {
@@ -652,9 +652,9 @@
},
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
"requested": "[2.7.0, )",
"resolved": "2.7.0",
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
},
"CommunityToolkit.Mvvm": {
"type": "CentralTransitive",
@@ -760,11 +760,11 @@
},
"SSH.NET": {
"type": "CentralTransitive",
"requested": "[2025.1.0, )",
"resolved": "2025.1.0",
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
"requested": "[2026.0.0, )",
"resolved": "2026.0.0",
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
"dependencies": {
"BouncyCastle.Cryptography": "2.6.2",
"BouncyCastle.Cryptography": "2.7.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
},
@@ -229,7 +229,7 @@
"type": "Project",
"dependencies": {
"NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )"
"SSH.NET": "[2026.0.0, )"
}
},
"AWSSDK.Core": {
@@ -249,9 +249,9 @@
},
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
"requested": "[2.7.0, )",
"resolved": "2.7.0",
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
},
"libsodium": {
"type": "CentralTransitive",
@@ -270,11 +270,11 @@
},
"SSH.NET": {
"type": "CentralTransitive",
"requested": "[2025.1.0, )",
"resolved": "2025.1.0",
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
"requested": "[2026.0.0, )",
"resolved": "2026.0.0",
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
"dependencies": {
"BouncyCastle.Cryptography": "2.6.2",
"BouncyCastle.Cryptography": "2.7.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
}
@@ -340,7 +340,7 @@
"type": "Project",
"dependencies": {
"NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )"
"SSH.NET": "[2026.0.0, )"
}
},
"dodossh.client.storage": {
@@ -379,9 +379,9 @@
},
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
"requested": "[2.7.0, )",
"resolved": "2.7.0",
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
},
"EFCore.NamingConventions": {
"type": "CentralTransitive",
@@ -481,11 +481,11 @@
},
"SSH.NET": {
"type": "CentralTransitive",
"requested": "[2025.1.0, )",
"resolved": "2025.1.0",
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
"requested": "[2026.0.0, )",
"resolved": "2026.0.0",
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
"dependencies": {
"BouncyCastle.Cryptography": "2.6.2",
"BouncyCastle.Cryptography": "2.7.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
}
@@ -41,11 +41,11 @@
},
"SSH.NET": {
"type": "Direct",
"requested": "[2025.1.0, )",
"resolved": "2025.1.0",
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
"requested": "[2026.0.0, )",
"resolved": "2026.0.0",
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
"dependencies": {
"BouncyCastle.Cryptography": "2.6.2",
"BouncyCastle.Cryptography": "2.7.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
},
@@ -306,7 +306,7 @@
"type": "Project",
"dependencies": {
"NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )"
"SSH.NET": "[2026.0.0, )"
}
},
"dodossh.client.terminal": {
@@ -317,9 +317,9 @@
},
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
"requested": "[2.7.0, )",
"resolved": "2.7.0",
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
},
"libsodium": {
"type": "CentralTransitive",
@@ -4,10 +4,44 @@
The throughput and backpressure harness the plan requires before any UI exists. Nothing
here needs a WebView: the flow control is what is most likely to be wrong, and it is pure
logic once ITerminalTransport is a seam.
And, since RendererReconnectionTests, the renderer's own half of the same protocol — the page
that reads these frames, run as a script rather than described in C#. It lives here rather than
in a test project of the shell's own because this is where the other end of the socket is
tested, and the two halves of "the renderer stays attached" are one mechanism: the host takes
a second upgrade over the first, and the page is what makes that second upgrade happen. A
project for the shell would still need a reference to this one's subject to say anything.
-->
<ItemGroup>
<ProjectReference Include="../../src/DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" />
</ItemGroup>
<ItemGroup>
<!--
A JavaScript engine, so terminal.js can be run as it ships instead of transcribed into C#.
See RendererPage for the trade against a node script, and for what an engine that is not
Chromium can and cannot prove.
-->
<PackageReference Include="Jint" />
</ItemGroup>
<ItemGroup>
<!--
◆ THE PAGE IS COPIED OUT OF THE SHELL PROJECT, WHICH IS THE ONE ODD THING IN THIS FILE.
Reaching across for a source file is not something else here does, and the alternatives are
worse: referencing DodoSSH.Client.Shell would drag Avalonia into a suite that draws nothing,
and a copy of the page checked in beside the tests would be a copy — the thing that quietly
stops matching what ships, which is precisely the failure this test exists to catch.
PreserveNewest rather than Always so that editing the page is what rebuilds it.
-->
<None Include="../../src/DodoSSH.Client.Shell/WebAssets/terminal.js"
Link="Renderer/terminal.js"
CopyToOutputDirectory="PreserveNewest" />
<None Include="Renderer/renderer-harness.js" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
@@ -0,0 +1,222 @@
'use strict';
/*
The world terminal.js is loaded into by RendererReconnectionTests.
It fakes exactly what the page's transport touches and nothing else: a WebSocket whose every step is
driven from the test, a clock that only moves when a test moves it, and the two DOM objects the page
reads at load. There is no Terminal, no fit addon and no WebGL here, because nothing on the reconnection
path builds a pane a test that opens a session will have to add them, and should, rather than this
file guessing now at what such a test would want.
NOTHING HERE MAY DECLARE A NAME terminal.js ALSO DECLARES.
Both files are evaluated as scripts into the same global scope, and the page's own `const root` against
a `var root` here is a SyntaxError before a line of either runs. That is why the page's two elements are
`pageRoot` and `pageBanner` below and reached through getElementById, which is how the page reaches them
anyway.
AND THE FAKE SOCKET'S BEHAVIOUR IS THE PART TO GET RIGHT.
close() is the one method with a rule that is not obvious and that the tests lean on: a socket closed
while it is still CONNECTING never fires 'close' at all, per the WebSocket specification, while one
closed after it opened does. The page relies on exactly that connect() closes the attempt it abandons
and expects to hear nothing back from it so a fake that fired 'close' either way would report the loop
the page's guards exist to prevent, and one that fired it neither way would hide it.
*/
var clock = { now: 0, next: 1, timers: {} };
/** Every socket the page has opened, in order, live or dead. */
var sockets = [];
function listeners(target) {
target.handlers = {};
target.addEventListener = function (name, handler) {
if (!target.handlers[name]) {
target.handlers[name] = [];
}
target.handlers[name].push(handler);
};
target.fire = function (name) {
var handlers = target.handlers[name] || [];
for (var i = 0; i < handlers.length; i++) {
handlers[i]({});
}
};
return target;
}
function FakeSocket(url, protocols) {
this.url = url;
this.protocols = protocols;
this.readyState = FakeSocket.CONNECTING;
this.binaryType = '';
listeners(this);
sockets.push(this);
}
FakeSocket.CONNECTING = 0;
FakeSocket.OPEN = 1;
FakeSocket.CLOSING = 2;
FakeSocket.CLOSED = 3;
FakeSocket.prototype.send = function () {};
/** What the page calls. See the note above on why a connecting socket goes quietly. */
FakeSocket.prototype.close = function () {
if (this.readyState === FakeSocket.CLOSED) {
return;
}
var wasConnecting = this.readyState === FakeSocket.CONNECTING;
this.readyState = FakeSocket.CLOSED;
if (!wasConnecting) {
this.fire('close');
}
};
var pageBanner = { textContent: 'Connecting…' };
var pageRoot = listeners({
dataset: { token: 'test-token', socket: 'ws://127.0.0.1:1/socket' },
appendChild: function () {},
});
var document = listeners({
hidden: false,
getElementById: function (id) { return id === 'root' ? pageRoot : pageBanner; },
createElement: function () { return { dataset: {}, style: {}, remove: function () {} }; },
});
var window = listeners({});
// The page warns through this on paths no reconnection test reaches. Present so that a test which does
// reach one fails on its own assertion rather than on a missing global.
var console = { warn: function () {}, log: function () {} };
function setTimeout(callback, delay) {
var id = clock.next++;
clock.timers[id] = { at: clock.now + (delay || 0), callback: callback };
return id;
}
function clearTimeout(id) {
delete clock.timers[id];
}
function ResizeObserver() {
this.observe = function () {};
}
// ── What the tests drive the page with ──────────────────────────────────────────────────────────────
/**
* Runs every timer due within the next `ms`, in the order they fall due.
*
* One at a time and re-scanned each round rather than collected up front, because a timer's callback
* routinely schedules the next one which is the whole shape of the page's retry and a snapshot taken
* before the first callback ran would miss it.
*/
function advance(ms) {
var target = clock.now + ms;
for (;;) {
var dueId = null;
var due = null;
for (var id in clock.timers) {
var timer = clock.timers[id];
if (timer.at <= target && (due === null || timer.at < due.at)) {
due = timer;
dueId = id;
}
}
if (due === null) {
break;
}
delete clock.timers[dueId];
clock.now = due.at;
due.callback();
}
clock.now = target;
}
/** How many sockets the page has opened since it loaded. */
function attempts() {
return sockets.length;
}
/** What the one status element says — the banner the user sees. */
function banner() {
return pageBanner.textContent;
}
/** Whether that attempt has been closed, by the page or by the far end. */
function isClosed(index) {
return sockets[index].readyState === FakeSocket.CLOSED;
}
/** The host accepted the upgrade. */
function accept(index) {
sockets[index].readyState = FakeSocket.OPEN;
sockets[index].fire('open');
}
/** An established socket goes away and the page is told. */
function drop(index) {
sockets[index].readyState = FakeSocket.CLOSED;
sockets[index].fire('close');
}
/** An attempt that never connects: 'error' then 'close', as a refused connection reports itself. */
function fail(index) {
sockets[index].readyState = FakeSocket.CLOSED;
sockets[index].fire('error');
sockets[index].fire('close');
}
/**
* A close arriving for a socket that died earlier the host's takeover abort, landing late.
*
* Separate from drop() because the point of it is the delay: the socket is already dead by the time the
* event is delivered, which is what a suspended renderer's queued events look like on the way back.
*/
function deliverLateClose(index) {
sockets[index].readyState = FakeSocket.CLOSED;
sockets[index].fire('close');
}
/** The screen the page is on goes away, and comes back. */
function becomeHidden() {
document.hidden = true;
document.fire('visibilitychange');
}
function becomeVisible() {
document.hidden = false;
document.fire('visibilitychange');
}
function takeFocus() {
window.fire('focus');
}
function comeOnline() {
window.fire('online');
}
var WebSocket = FakeSocket;
@@ -0,0 +1,79 @@
using System.Globalization;
using Jint;
namespace DodoSSH.Client.Terminal.Tests;
/// <summary>
/// One load of <c>terminal.js</c>, in a fake browser, for one test.
/// </summary>
/// <remarks>
/// <para>
/// <b>The page's own source is what runs.</b> Not a transcription of its logic into C# — that would test a
/// copy, and the copy would be the thing that stayed correct. The file is read from the shell project and
/// evaluated as it ships, so a change to the page that breaks the reconnection rules fails here.
/// </para>
/// <para>
/// <b>Jint rather than node, and that is a deliberate trade.</b> A node script would be the obvious way to
/// run JavaScript and would need node on every machine and in every CI job that runs the suite — so it
/// would be a second test command, run separately, and the first thing to be forgotten. This runs inside
/// <c>dotnet test</c> with everything else. What it costs is that the engine is not the engine the page
/// actually runs in: Jint is not Chromium, so this can prove the page's own logic and can prove nothing
/// about how WebView2 or Android's WebView behave. That boundary is exactly where
/// <c>docs/manual-checks.md</c> picks up — see 1.10 and 11.12a.
/// </para>
/// <para>
/// A fresh engine per test, because the page is a script with module-level state and there is no unloading
/// it: two tests sharing one engine would share a socket list, a clock and a backoff.
/// </para>
/// </remarks>
internal sealed class RendererPage
{
private readonly Engine engine;
private RendererPage(Engine engine) => this.engine = engine;
/// <summary>How many sockets the page has opened since it loaded.</summary>
/// <remarks>
/// The measurement nearly every test here turns on, and it is deliberately a count of *attempts*
/// rather than of live sockets: the failures being guarded against are a page that stops trying and a
/// page that never stops, and both are counted rather than observed.
/// </remarks>
public int Attempts => (int)engine.Evaluate("attempts()").AsNumber();
/// <summary>What the status element says — the banner a user would be looking at.</summary>
public string Banner => engine.Evaluate("banner()").AsString();
/// <summary>
/// Loads the harness and then the page, leaving the page exactly as it is a moment after the WebView
/// navigated to it: one socket opened and still connecting.
/// </summary>
public static RendererPage Load()
{
var browser = new Engine();
// Order matters and is not incidental: the page connects on its last line, so every global it
// touches — the socket constructor above all — has to be in place before it is evaluated.
browser.Execute(Read("Renderer/renderer-harness.js"));
browser.Execute(Read("Renderer/terminal.js"));
return new RendererPage(browser);
}
/// <summary>Runs a line of the harness's own vocabulary — <c>accept(0)</c>, <c>advance(1000)</c>.</summary>
public void Do(string script) => engine.Execute(script);
/// <summary>Whether that attempt has been closed, by the page or by the far end.</summary>
public bool IsClosed(int attempt) =>
engine.Evaluate(
string.Create(CultureInfo.InvariantCulture, $"isClosed({attempt})"))
.AsBoolean();
/// <remarks>
/// Both files are copied beside the test assembly by the project file, the page out of the shell
/// project it belongs to. Read from disk rather than embedded so that the copy which runs here is the
/// same bytes the host serves, with nothing in between that could go stale.
/// </remarks>
private static string Read(string relativePath) =>
File.ReadAllText(Path.Combine(AppContext.BaseDirectory, relativePath));
}
@@ -0,0 +1,192 @@
namespace DodoSSH.Client.Terminal.Tests;
/// <summary>
/// The renderer page's half of staying attached — <c>terminal.js</c>'s <c>connect()</c> and what drives it.
/// </summary>
/// <remarks>
/// <para>
/// The host's half is <see cref="TerminalDataPlaneTests"/>, and the two are one mechanism: a socket that
/// drops is ordinary here, and the page coming back for another is what makes it ordinary. What these
/// tests protect is the property that failure of this mechanism has no other symptom — a terminal whose
/// page has given up looks exactly like a terminal whose remote has gone quiet, except for a line of text
/// nobody reads twice.
/// </para>
/// <para>
/// Four of these were written against a page that failed them — the stale close, the handshake that never
/// finishes, and the two wake-ups — and the rest describe behaviour that was already right and is easy to
/// break while fixing those. The two that assert a wake-up does *nothing* pass against either version,
/// which is the point of them: they are what stops the cure being worse, and they can only ever fail
/// against a future change. See <see cref="RendererPage"/> for how the real file is loaded and for what
/// this cannot reach.
/// </para>
/// </remarks>
public sealed class RendererReconnectionTests
{
[Fact]
public void ThePage_ConnectsWhenItLoads()
{
var page = RendererPage.Load();
page.Attempts.ShouldBe(1);
}
[Fact]
public void ADroppedSocket_IsRetriedAndTheBannerClears()
{
var page = RendererPage.Load();
page.Do("accept(0)");
page.Banner.ShouldBe("");
page.Do("drop(0)");
page.Banner.ShouldStartWith("Reconnecting");
page.Do("advance(1000)");
page.Attempts.ShouldBe(2);
page.Do("accept(1)");
page.Banner.ShouldBe("");
}
/// <remarks>
/// The wait grows within one outage and goes back to a second once a socket has actually opened, so
/// that the next outage is not paid for at the previous one's rate.
/// </remarks>
[Fact]
public void TheWait_GrowsWithinAnOutageAndResetsAfterIt()
{
var page = RendererPage.Load();
page.Do("fail(0); advance(1000)");
page.Attempts.ShouldBe(2);
page.Do("fail(1); advance(1999)");
page.Attempts.ShouldBe(2);
page.Do("advance(1)");
page.Attempts.ShouldBe(3);
page.Do("accept(2); drop(2); advance(1000)");
page.Attempts.ShouldBe(4);
}
/// <summary>
/// A close for a socket the page has already replaced must not start a reconnect.
/// </summary>
/// <remarks>
/// The loop this forbids costs nothing to enter and never leaves: the host aborts the displaced socket
/// on every takeover — see <c>TerminalDataPlane.UpgradeAsync</c> — so a stale close that schedules a
/// retry displaces the socket that has just succeeded, whose own close schedules the next. The visible
/// end of it is a terminal that reconnects every second forever with the banner up for most of it.
/// </remarks>
[Fact]
public void AStaleClose_DoesNotDisplaceTheSocketThatSucceeded()
{
var page = RendererPage.Load();
page.Do("accept(0); drop(0); advance(1000)");
page.Do("accept(1)");
page.Attempts.ShouldBe(2);
// The first socket's end, arriving after the page has moved on.
page.Do("deliverLateClose(0)");
page.Do("advance(60000)");
page.Attempts.ShouldBe(2);
page.Banner.ShouldBe("");
}
/// <summary>
/// An attempt that never finishes its handshake is given up on rather than waited on forever.
/// </summary>
/// <remarks>
/// Every other retry in the page is scheduled by a close or an error, so a socket that reports neither
/// — which is what a renderer suspended mid-handshake leaves behind — used to schedule nothing at all.
/// The page then held a banner saying it was reconnecting with no timer pending and no socket coming,
/// for the rest of its life.
/// </remarks>
[Fact]
public void AHandshakeThatNeverFinishes_IsAbandonedAndRetried()
{
var page = RendererPage.Load();
// Nothing whatever from the first attempt: no open, no error, no close.
page.Do("advance(5000)");
page.Attempts.ShouldBe(1);
page.Do("advance(1000)");
page.Attempts.ShouldBe(2);
page.IsClosed(0).ShouldBeTrue();
page.Do("accept(1)");
page.Banner.ShouldBe("");
}
/// <summary>
/// Coming back to the page reconnects it, without waiting for a timer that may not be running.
/// </summary>
/// <remarks>
/// The case the whole wake-up path exists for, and the one a test can only approximate: the harness's
/// clock stands still here because a real hidden page's clock is throttled rather than stopped, and
/// standing still is the honest worst case of that. What is being asserted is that the page does not
/// need the clock at all to notice it is back.
/// </remarks>
[Fact]
public void BecomingVisibleAgain_ReconnectsWithoutTheTimer()
{
var page = RendererPage.Load();
page.Do("accept(0); becomeHidden(); drop(0)");
page.Attempts.ShouldBe(1);
page.Do("becomeVisible()");
page.Attempts.ShouldBe(2);
// And the timer that was pending when the page woke must not open a third socket on top of the
// one that just succeeded — which would be the takeover loop, entered from the other side.
page.Do("accept(1); advance(60000)");
page.Attempts.ShouldBe(2);
page.Banner.ShouldBe("");
}
[Fact]
public void TakingTheKeyboardBack_AlsoReconnects()
{
var page = RendererPage.Load();
page.Do("accept(0); drop(0); takeFocus()");
page.Attempts.ShouldBe(2);
}
/// <remarks>
/// The wake-ups fire on gestures as ordinary as clicking the window, so the check they make has to be
/// the thing that keeps them cheap rather than the frequency. A page whose socket is up must treat all
/// of them as nothing at all — anything else would be the takeover loop with a person's mouse driving it.
/// </remarks>
[Fact]
public void WakingUpOverAHealthySocket_DoesNothing()
{
var page = RendererPage.Load();
page.Do("accept(0)");
page.Do("takeFocus(); becomeVisible(); comeOnline(); becomeHidden(); becomeVisible()");
page.Attempts.ShouldBe(1);
page.Banner.ShouldBe("");
}
/// <remarks>
/// An attempt already in flight is left to finish or to time out. Restarting it on every wake-up would
/// mean a page being clicked during a slow handshake never completing one.
/// </remarks>
[Fact]
public void WakingUpWhileConnecting_LeavesTheAttemptAlone()
{
var page = RendererPage.Load();
page.Do("takeFocus(); becomeVisible(); comeOnline()");
page.Attempts.ShouldBe(1);
}
}
@@ -416,6 +416,36 @@ public sealed class TerminalDataPlaneTests : IAsyncDisposable
TimeProvider.System,
new TerminalPumpOptions { FlushInterval = TimeSpan.FromMilliseconds(10) });
/// <summary>
/// Opens a renderer's socket and returns once the plane is actually holding it.
/// </summary>
/// <remarks>
/// <para>
/// <b>Connected and attached are two different moments, and the gap between them is where this used to
/// flake.</b> <c>ClientWebSocket.ConnectAsync</c> completes on the 101, which
/// <see cref="TerminalDataPlane.UpgradeAsync"/> writes before it has a <see cref="WebSocket"/> to
/// attach — it builds one from the stream and swaps it in a few instructions later, on the accept
/// thread. A frame sent in between is dropped, by design rather than by accident: the transport has
/// nowhere to put a frame for a renderer that is not there, and queueing it is the unbounded growth the
/// credit window exists to prevent.
/// </para>
/// <para>
/// So a test that connected and immediately expected a frame was racing that window on every run. It
/// lost one on CI — <c>Output_ReachesTheRenderer</c> read the output frame first and asked why it was
/// not the session's opening one, the opening one having been dropped a moment earlier — which is a
/// scheduling accident on a loaded machine and says nothing whatever about the transport.
/// </para>
/// <para>
/// Production does not race it and needs no change: everything that opens a session waits on
/// <c>TerminalWorkspace.WaitForRendererAsync</c> first, and that resolves from the same few lines this
/// event is raised from.
/// </para>
/// <para>
/// Subscribed before the connection rather than after it, because the event is raised on the accept
/// thread and can be over before <c>ConnectAsync</c> has returned here. Bounded, so that a socket that
/// never attaches fails this helper rather than hanging the suite in a later receive.
/// </para>
/// </remarks>
private async Task<ClientWebSocket> ConnectAsync(
string? token = "",
string? origin = null)
@@ -433,17 +463,31 @@ public sealed class TerminalDataPlaneTests : IAsyncDisposable
"Origin",
origin ?? string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{plane.Port}"));
var attached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
void OnAttached(object? sender, EventArgs e) => attached.TrySetResult();
plane.SocketAttached += OnAttached;
try
{
await socket.ConnectAsync(
new Uri($"ws://127.0.0.1:{plane.Port}{TerminalDataPlane.SocketPath}"),
TestContext.Current.CancellationToken);
await attached.Task.WaitAsync(
TimeSpan.FromSeconds(5),
TestContext.Current.CancellationToken);
}
catch
{
socket.Dispose();
throw;
}
finally
{
plane.SocketAttached -= OnAttached;
}
return socket;
}
@@ -538,10 +538,26 @@ public sealed class TerminalWorkspaceTests
new("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant"));
/// <remarks>
/// <para>
/// Attaches the way the real page does: by fetching the served page, reading the token and socket URL
/// back out of it, and presenting them on the upgrade — rather than reaching into the workspace for a
/// token it does not expose. A shortcut here would prove only that a socket can be opened, not that the
/// workspace serves a page a renderer could actually attach with.
/// </para>
/// <para>
/// And it waits for the attach rather than only for the handshake, for the reason
/// <c>TerminalDataPlaneTests.ConnectAsync</c> sets out at length: the 101 is written before the socket
/// is attachable, and a frame sent in between is dropped. A caller that opens a session on the socket
/// this returns and then reads its opening frame is exactly the shape that loses that race.
/// </para>
/// <para>
/// <c>WaitForRendererAsync</c> answers only for the first renderer ever to attach, so a second call
/// returns immediately without proving anything about the second socket. That is enough here and is
/// not luck: the only frames a second socket is given are the replay, and the replay is *caused* by the
/// attach — <see cref="TerminalWorkspace.RendererReattached"/> and the frames before it cannot be sent
/// early. The day a test sends something else down a reattached socket, this needs the data plane's own
/// <c>SocketAttached</c>, which the workspace does not forward today.
/// </para>
/// </remarks>
private static async Task<ClientWebSocket> ConnectRendererAsync(TerminalWorkspace workspace)
{
@@ -561,6 +577,8 @@ public sealed class TerminalWorkspaceTests
try
{
await client.ConnectAsync(new Uri(socketUrl), TestContext.Current.CancellationToken);
await workspace.WaitForRendererAsync(TestContext.Current.CancellationToken);
}
catch
{
@@ -2,6 +2,15 @@
"version": 2,
"dependencies": {
"net10.0": {
"Jint": {
"type": "Direct",
"requested": "[4.16.0, )",
"resolved": "4.16.0",
"contentHash": "YHofgoVtjWzqmG2GsGsp6eYMmcBfGgJOcH+Ki2UdZXNsYgKGnWrK2hSqRSPG/6HqeiipT5YPhzEH+bwxFO+YAQ==",
"dependencies": {
"Acornima": "1.7.0"
}
},
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.137, )",
@@ -48,6 +57,11 @@
"xunit.v3.mtp-v1": "[3.2.2]"
}
},
"Acornima": {
"type": "Transitive",
"resolved": "1.7.0",
"contentHash": "a2I4O4qkuAdB0oSaGz6/k0n/bXxMbGcLBra5dNTTVSLmTwM7uORA8ebpOrNMmDfFWOMwvuaRL6gPprRV4HTV0w=="
},
"Castle.Core": {
"type": "Transitive",
"resolved": "5.1.1",
@@ -217,7 +231,7 @@
"type": "Project",
"dependencies": {
"NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )"
"SSH.NET": "[2026.0.0, )"
}
},
"dodossh.client.terminal": {
@@ -228,9 +242,9 @@
},
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
"requested": "[2.7.0, )",
"resolved": "2.7.0",
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
},
"libsodium": {
"type": "CentralTransitive",
@@ -249,11 +263,11 @@
},
"SSH.NET": {
"type": "CentralTransitive",
"requested": "[2025.1.0, )",
"resolved": "2025.1.0",
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
"requested": "[2026.0.0, )",
"resolved": "2026.0.0",
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
"dependencies": {
"BouncyCastle.Cryptography": "2.6.2",
"BouncyCastle.Cryptography": "2.7.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
}
@@ -217,7 +217,7 @@
"type": "Project",
"dependencies": {
"NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )"
"SSH.NET": "[2026.0.0, )"
}
},
"dodossh.client.transfer": {
@@ -228,9 +228,9 @@
},
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
"requested": "[2.7.0, )",
"resolved": "2.7.0",
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
},
"libsodium": {
"type": "CentralTransitive",
@@ -249,11 +249,11 @@
},
"SSH.NET": {
"type": "CentralTransitive",
"requested": "[2025.1.0, )",
"resolved": "2025.1.0",
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
"requested": "[2026.0.0, )",
"resolved": "2026.0.0",
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
"dependencies": {
"BouncyCastle.Cryptography": "2.6.2",
"BouncyCastle.Cryptography": "2.7.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
}
@@ -390,9 +390,9 @@
},
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
"requested": "[2.7.0, )",
"resolved": "2.7.0",
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
},
"EFCore.NamingConventions": {
"type": "CentralTransitive",
@@ -431,11 +431,11 @@
},
"SSH.NET": {
"type": "CentralTransitive",
"requested": "[2025.1.0, )",
"resolved": "2025.1.0",
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
"requested": "[2026.0.0, )",
"resolved": "2026.0.0",
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
"dependencies": {
"BouncyCastle.Cryptography": "2.6.2",
"BouncyCastle.Cryptography": "2.7.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
},
+8 -8
View File
@@ -436,7 +436,7 @@
"type": "Project",
"dependencies": {
"NSec.Cryptography": "[26.4.0, )",
"SSH.NET": "[2025.1.0, )"
"SSH.NET": "[2026.0.0, )"
}
},
"dodossh.client.storage": {
@@ -486,9 +486,9 @@
},
"BouncyCastle.Cryptography": {
"type": "CentralTransitive",
"requested": "[2.6.2, )",
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
"requested": "[2.7.0, )",
"resolved": "2.7.0",
"contentHash": "U+12df8UEWHgBi04YVf/Lgi2dy3SItlIYvHjjEVa/BngCQIzDCDRBk50DDByCfDvSbe5pRNFr3b7UrVK2kMcLw=="
},
"EFCore.NamingConventions": {
"type": "CentralTransitive",
@@ -599,11 +599,11 @@
},
"SSH.NET": {
"type": "CentralTransitive",
"requested": "[2025.1.0, )",
"resolved": "2025.1.0",
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
"requested": "[2026.0.0, )",
"resolved": "2026.0.0",
"contentHash": "Yu9dirPq8l3oaat0+OQ7K0nUf5MmYltpia5UGqsApTG4zTPvBC1cxbNnC3NERij26dUST0A3Ef1QdHSn5ArbWQ==",
"dependencies": {
"BouncyCastle.Cryptography": "2.6.2",
"BouncyCastle.Cryptography": "2.7.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
}
}