Public Access
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e61fc72b4 | ||
|
|
8a4058ce3a | ||
|
|
2d95366552 | ||
|
|
9ead42171c | ||
|
|
597e5ae016 | ||
|
|
483ae1fed9 | ||
|
|
7ee2aacdc9 | ||
|
|
dec2cf164a | ||
|
|
7d1f0a4b53 | ||
|
|
fd92b80d4f | ||
|
|
dabc691c35 | ||
|
|
31c836f6ca | ||
|
|
b8c94ecb73 |
No files matched your search
@@ -29,6 +29,18 @@ jobs:
|
||||
build:
|
||||
name: build and test
|
||||
runs-on: [linux]
|
||||
# ◆ A CEILING, BECAUSE THIS FILE HAD NONE AND A HUNG TEST RAN UNTIL SOMEBODY NOTICED.
|
||||
#
|
||||
# One test in DodoSSH.Client.App.Tests waited on a named pipe that, on Linux, nothing was ever going
|
||||
# to answer — and it waited on a token with no timeout, so the job did not fail, it sat there. It was
|
||||
# killed by hand after twenty minutes, having reported nothing but the eighteen suites that had
|
||||
# already passed.
|
||||
#
|
||||
# Twenty-five minutes against a job that ordinarily takes three. It is not a performance budget and
|
||||
# must not be read as one: a build that legitimately grows past this should have the number raised.
|
||||
# What it buys is that the failure mode is a red job with a named step rather than a runner somebody
|
||||
# has to go and stop.
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
# act_runner runs every `uses:` action with node inside the job container, and this
|
||||
# runner's image has none — the run died on the first line of actions/checkout with
|
||||
@@ -220,7 +232,12 @@ jobs:
|
||||
- name: build
|
||||
run: dotnet build DodoSSH.slnx --no-restore --configuration Release
|
||||
|
||||
# Tighter than the job's own ceiling, and deliberately so: a hang here is the likeliest of the
|
||||
# lot — it is the only step running code written for this repository rather than a tool — and a
|
||||
# step-level timeout names the step in the failure instead of leaving a reader to work out which
|
||||
# of fifteen was still running. Ten minutes against a suite that takes two.
|
||||
- name: test
|
||||
timeout-minutes: 10
|
||||
run: dotnet test DodoSSH.slnx --no-build --configuration Release
|
||||
|
||||
# The one build shape nothing else here exercises: a self-contained RID-specific publish. Its
|
||||
@@ -556,6 +573,8 @@ jobs:
|
||||
exit 0
|
||||
android:
|
||||
name: android head
|
||||
# See the build job. The docker pull and the toolchain image make this one legitimately slower.
|
||||
timeout-minutes: 40
|
||||
runs-on: [linux]
|
||||
# Writes the nightly release at the end of the job; see that step for what the capability is and why
|
||||
# it is acceptable here and nowhere else. Job-scoped, so nothing else in this file gains it.
|
||||
@@ -915,7 +934,10 @@ jobs:
|
||||
# never made.
|
||||
published="$(curl -fsS -H "$auth" "$api/releases/$release")"
|
||||
for name in $names; do
|
||||
if ! printf '%s' "$published" | grep -qF "\"name\":\"$name\""; then
|
||||
# grep without -q, which has the same exit status and reads to the end of its input. A
|
||||
# reader that stops early under pipefail turns the writer's SIGPIPE into this condition's
|
||||
# answer; see the note in scripts/ci-linux-rpm.sh, where that cost a green build.
|
||||
if ! printf '%s' "$published" | grep -F "\"name\":\"$name\"" >/dev/null; then
|
||||
echo "The release was created but $name is not on it:" >&2
|
||||
printf '%s\n' "$published" >&2
|
||||
exit 1
|
||||
@@ -926,6 +948,8 @@ jobs:
|
||||
|
||||
desktop:
|
||||
name: desktop nightly
|
||||
# See the build job. Two publishes, a pack and an upload of about 120 MB.
|
||||
timeout-minutes: 40
|
||||
# Gated on the tests, like the image job and for a stronger version of its reason: this one is
|
||||
# installed by people and replaces itself afterwards. Nothing anybody runs should come out of a
|
||||
# commit whose suite was red.
|
||||
@@ -1226,7 +1250,10 @@ jobs:
|
||||
published="$(curl -fsS -H "$auth" "$api/releases/tags/$TAG")"
|
||||
|
||||
for name in releases.win-nightly.json DodoSSH.Desktop.Nightly-win-nightly-Setup.exe; do
|
||||
if ! printf '%s' "$published" | grep -qF "\"name\":\"$name\""; then
|
||||
# grep without -q, which has the same exit status and reads to the end of its input. A
|
||||
# reader that stops early under pipefail turns the writer's SIGPIPE into this condition's
|
||||
# answer; see the note in scripts/ci-linux-rpm.sh, where that cost a green build.
|
||||
if ! printf '%s' "$published" | grep -F "\"name\":\"$name\"" >/dev/null; then
|
||||
echo "The release was created but $name is not on it:" >&2
|
||||
printf '%s\n' "$published" >&2
|
||||
exit 1
|
||||
@@ -1279,7 +1306,8 @@ jobs:
|
||||
# upload it never made.
|
||||
withRpm="$(curl -fsS -H "$auth" "$api/releases/$release")"
|
||||
for name in $linuxNames; do
|
||||
if ! printf '%s' "$withRpm" | grep -qF "\"name\":\"$name\""; then
|
||||
# grep without -q; see the sibling loops above and scripts/ci-linux-rpm.sh.
|
||||
if ! printf '%s' "$withRpm" | grep -F "\"name\":\"$name\"" >/dev/null; then
|
||||
echo "The release was created but $name is not on it:" >&2
|
||||
printf '%s\n' "$withRpm" >&2
|
||||
exit 1
|
||||
@@ -1290,6 +1318,8 @@ jobs:
|
||||
|
||||
image:
|
||||
name: api image
|
||||
# See the build job. A cold layer cache is the slow case here.
|
||||
timeout-minutes: 40
|
||||
# Gated on the tests rather than parallel with them, which costs a few minutes of wall
|
||||
# clock on every main commit and buys the thing worth having: no image reaches the
|
||||
# registry from a commit whose tests were red. An image is not a build artefact anyone
|
||||
|
||||
+8
-1
@@ -87,9 +87,16 @@ Releases/
|
||||
*.pfx
|
||||
*.p12
|
||||
*.snk
|
||||
# Signing keystores, with one exception that is tracked deliberately. build/nightly.keystore is
|
||||
# committed in the open on purpose — see ADR 0014 for why a key everybody holds is what makes the
|
||||
# nightly channel safe to run in CI — and a file already tracked is unaffected by an ignore rule
|
||||
# anyway. The negation is here so that reading this list does not suggest the tracked one is an
|
||||
# oversight. The *release* keystore is a real secret under ADR 0011 rule 1 and must never land here.
|
||||
*.keystore
|
||||
*.jks
|
||||
!build/nightly.keystore
|
||||
appsettings.Local.json
|
||||
appsettings.*.Local.json
|
||||
.env
|
||||
.env.*
|
||||
!deploy/.env.example
|
||||
secrets/
|
||||
@@ -1,7 +1,12 @@
|
||||
# Development dependencies only: PostgreSQL and Keycloak.
|
||||
#
|
||||
# The API itself runs from the IDE or `dotnet run`, so the inner loop stays fast while the
|
||||
# schema is still churning. The production stack is deploy/docker-compose.yml.
|
||||
# schema is still churning.
|
||||
#
|
||||
# There is no production compose file in this repository. This one said there was, naming a
|
||||
# deploy/docker-compose.yml that has never existed in any commit. What ships instead is the
|
||||
# container image the CI image job builds and pushes; a compose file to run it belongs to the
|
||||
# self-hosting guide in M4, and until that exists the honest thing to say is that it does not.
|
||||
#
|
||||
# docker compose -f deploy/docker-compose.dev.yml up -d
|
||||
# dotnet run --project src/DodoSSH.Api
|
||||
|
||||
@@ -202,6 +202,44 @@ That is what the "is this still the page's socket" guard in `connect()` exists t
|
||||
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.
|
||||
|
||||
### 1.10a A remote that floods the terminal does not grow the process · **watch Task Manager**
|
||||
|
||||
Open a shell and run `yes dodossh`. Leave it running for a full minute with the tab in front of you, then
|
||||
switch to another screen and leave it another minute, then come back. Watch the application's memory in
|
||||
Task Manager (Details tab, *Memory (active private working set)*) the whole time.
|
||||
|
||||
**Pass:** memory settles at a plateau and stays there — the number moves as the GC breathes, but there is no
|
||||
staircase. Ctrl+C, and the prompt comes back straight away. Somewhere in the scrollback there may be a dim
|
||||
grey line reading `── N MB of output was dropped; the terminal could not keep up ──`; that line is a pass,
|
||||
not a failure. It is this client saying it discarded output it could not draw, which is what keeps the
|
||||
plateau flat, and the number after it tells you how far behind the display got.
|
||||
|
||||
**Failure means:** a working set that climbs steadily for the whole minute and does not come back down after
|
||||
Ctrl+C is the defect this check exists for, and it will not be in this repository's own buffers — it is
|
||||
SSH.NET's. `ShellStream` copies everything the channel delivers into a buffer that doubles and is never
|
||||
given back, and the channel keeps delivering because `Channel.OnData` re-opens the SSH receive window as
|
||||
bytes *arrive* rather than as they are consumed. Measured against the container in
|
||||
`DodoSSH.Client.Ssh.Tests`, a reader that stops entirely leaves that buffer holding 44 MB after five
|
||||
seconds. The only thing that keeps it empty is `TerminalSessionPump` reading continuously and bounding what
|
||||
it holds in `OutputBacklog` instead — so a staircase here means something has put a wait back into that read
|
||||
loop. `ARunawayRemote_KeepsBeingReadAndStaysInsideTheBacklog` is the test that fails first.
|
||||
|
||||
### 1.10b A tab whose shell exited stops costing anything · **needs a debugger or `netstat`**
|
||||
|
||||
Open a shell, type `exit`, and **leave the tab open**. Note the host and port. Then, from a terminal outside
|
||||
the application, run `netstat -ano | findstr <port>` and look for the connection to that host.
|
||||
|
||||
**Pass:** the connection is gone within a second or two of the shell exiting, even though the tab is still on
|
||||
screen showing the session ended and still offering RECONNECT. Clicking that reconnect still works, and
|
||||
closing the tab afterwards still works.
|
||||
|
||||
**Failure means:** a connection that is still ESTABLISHED for as long as the tab is open is the session
|
||||
holding an authenticated socket it has no further use for — with a keep-alive putting a packet on the wire
|
||||
every thirty seconds and SSH.NET's read buffer still at whatever size the session's busiest moment grew it
|
||||
to. It used to do exactly that, until whichever tab it was got closed. `TerminalWorkspace.ReleaseAsync` is
|
||||
what gives it back now, and `ASessionWhoseRemoteHasExited_GivesBackItsConnectionWithoutWaitingForTheTab` is
|
||||
the test that says so.
|
||||
|
||||
### 1.11 The right button copies and pastes, and never opens a menu
|
||||
|
||||
Open a shell and run something that prints a few lines — `ls -la /etc`. Drag a selection across part of the
|
||||
@@ -252,6 +290,38 @@ registered in the capture phase, that no native menu is drawn once it is prevent
|
||||
`ClipboardWriter` and `ClipboardReader` in each `App.axaml.cs` — and the view model is deliberately silent
|
||||
when neither is there, because there is no status line on this screen to say it on.
|
||||
|
||||
### 1.12 Something is on screen while it starts
|
||||
|
||||
Close DodoSSH and start it again. Watch the first second.
|
||||
|
||||
**Pass:** a small panel — about 420 pixels wide, no titlebar — appears saying **DodoSSH** and *Starting…*,
|
||||
and is then replaced by the main window. Measured on a debug build against a cold
|
||||
profile: the panel at 668 ms, the window at 3.1 s. A release install is quicker and the shape is the same.
|
||||
What must never come back is the old behaviour, which was nothing at all on screen for the whole of it.
|
||||
|
||||
**Pass, and worth checking separately:** the panel is **gone** once the window is up, there is never a moment
|
||||
with two windows both claiming to be DodoSSH, and clicking another application while the panel is up puts
|
||||
that application in front of it. The last one is deliberate — a splash that stays on top is a splash sitting
|
||||
over whatever you gave up and went back to.
|
||||
|
||||
**And the failure state, which is the part with something to get wrong.** It needs provoking: put a
|
||||
*directory* where `cache.db` should be — `%LOCALAPPDATA%\DodoSSH.Nightly\cache.db` for the nightly — and
|
||||
start the application.
|
||||
|
||||
**Pass:** the panel stays and the line becomes *DodoSSH could not start:* followed by the reason and
|
||||
*Press Escape to close.* Escape closes it. Remove the directory afterwards.
|
||||
|
||||
There is deliberately nothing else on the card to contradict that line — the progress bar this check used to
|
||||
describe was taken off in "Take the frozen bar off the splash, and leave the line that is true", because an
|
||||
indeterminate bar over a composition that had stopped was the one thing a splash must never do.
|
||||
|
||||
**Failure means:** a panel left saying *Starting…* is the main window having failed to show with nothing
|
||||
catching it, which is the one outcome worse than the crash it replaced — `DodoSshApp.ComposeBehind` exists to
|
||||
make it impossible. Two windows at once is the show-then-close order in that method having been swapped:
|
||||
closing the only open window ends the process, so the real window is shown while the panel is still up.
|
||||
Nothing appearing at all means composition is running in the same dispatcher turn that has to paint the
|
||||
panel, which is what posting it at `DispatcherPriority.Background` prevents.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Known Hosts as its own page
|
||||
@@ -2890,6 +2960,43 @@ offer a nightly, ever, however many have been published since. It reads a differ
|
||||
prereleases; if a nightly version is ever offered there, stop and treat it as a release-channel incident
|
||||
rather than as a bug in the nightly.
|
||||
|
||||
### 16.11 One instance, and what a second launch does instead
|
||||
|
||||
With DodoSSH running, start it again — Start menu, desktop shortcut, or the executable directly. Do it three
|
||||
times: with the window in front, with it minimised, and with it behind another application.
|
||||
|
||||
**Pass:** no second window appears, and the one already open comes forward, un-minimised if it was
|
||||
minimised. **A flashing taskbar button rather than a raised window is also a pass**: Windows refuses the
|
||||
foreground to a process that did not receive the input, which is its own rule about focus stealing and not
|
||||
something to defeat. Nothing happening at all is the failure.
|
||||
|
||||
**And the side-by-side case, which is the whole reason the lock lives where it does.** 16.10's arrangement,
|
||||
both builds installed, both started.
|
||||
|
||||
**Pass:** both run. They are separate applications with separate profiles, and the lock is a file inside the
|
||||
profile — so neither can see the other's. Two people signed in to one machine is the same property from the
|
||||
other direction and needs no check of its own.
|
||||
|
||||
**And the update relaunch · needs a release.** Take an update (16.7) and press RESTART NOW.
|
||||
|
||||
**Pass:** the application comes back. This is the race worth naming: the updater starts the new build as the
|
||||
old one is exiting, and a lock held a moment too long would make the new process refuse itself and disappear
|
||||
— an update that looked like an uninstall. `SingleInstance.Claim` keeps trying for three seconds while the
|
||||
lock is held by something that does not answer its pipe, which is what covers it.
|
||||
|
||||
**Failure means:** a second window means the lock is not being taken. A profile directory that cannot be
|
||||
written to answers "you are the only one" on purpose — refusing to start an SSH client over a permissions
|
||||
problem would be the wrong trade — so check that the profile exists, is writable, and has an
|
||||
`instance.lock` in it while the application runs. A second launch that exits without raising the first means
|
||||
the lock worked and the doorbell did not: the pipe name is derived from the profile path, so two processes
|
||||
disagreeing about it means they disagree about which profile they are in. Two windows *after an update* is
|
||||
the opposite failure — the outgoing process never released the lock, which means something is holding it
|
||||
open past `Shutdown`.
|
||||
|
||||
`SingleInstanceTests` covers everything reachable without a second process: the lock, its release, the
|
||||
doorbell answering more than once, and two profiles not excluding each other. What is left for a person is a
|
||||
second *executable* and a real window coming forward.
|
||||
|
||||
---
|
||||
|
||||
## Phase 17 — Installing and updating the phone
|
||||
|
||||
+24
-5
@@ -223,6 +223,25 @@ echo "==> $(basename "$rpm_file"), $(wc -c < "$rpm_file") bytes"
|
||||
#
|
||||
# Everything above can succeed and still produce something nobody can install, and none of the ways
|
||||
# that happens are visible in an exit code. Each check below is one of them.
|
||||
#
|
||||
# ◆ EVERY READER IN A PIPELINE HERE MUST CONSUME ALL OF ITS INPUT, AND THAT IS NOT A STYLE RULE.
|
||||
#
|
||||
# `set -o pipefail` at the top of this file means a pipeline's status is the rightmost non-zero one.
|
||||
# A reader that stops early — `grep -q`, `head -n` — closes the pipe while the writer is still
|
||||
# writing, the writer dies of SIGPIPE with status 141, and pipefail hands that 141 to the `if`. So a
|
||||
# check that found what it was looking for reports the opposite, and a diagnostic printed on the way
|
||||
# to `exit 1` kills the script under `set -e` before it gets there.
|
||||
#
|
||||
# It is a threshold rather than a constant, which is what made it look like somebody else's bug: the
|
||||
# writer only blocks once it has more than a pipe buffer left to write, about 64 KB on Linux. `$files`
|
||||
# is one line per packaged file, so this passed for as long as the published tree was small and then
|
||||
# failed on the commit that pushed the path text past 64 KB — reporting `/opt/DodoSSH/DodoSSH` missing
|
||||
# from a package that plainly contained it, and exiting 141 rather than the 1 it meant to.
|
||||
#
|
||||
# Hence `grep pattern >/dev/null` rather than `grep -q pattern`, which has the same exit status and
|
||||
# reads to the end; and `awk 'NR<=40'` rather than `head -40`, which prints the same lines and reads
|
||||
# to the end. Nothing here is allowed to stop early, and `|| true` is not the fix — it would hide a
|
||||
# real failure of the writer alongside the imaginary one.
|
||||
|
||||
requires="$(rpm -qp --requires "$rpm_file")"
|
||||
provides="$(rpm -qp --provides "$rpm_file")"
|
||||
@@ -236,7 +255,7 @@ files="$(rpm -qp --list "$rpm_file")"
|
||||
carried="$(printf '%s\n' "$files" | sed -n 's|^/opt/.*/\(lib[^/]*\.so[^/]*\)$|\1|p' | sort -u)"
|
||||
unsatisfiable=""
|
||||
for soname in $carried; do
|
||||
if printf '%s\n' "$requires" | grep -qF "$soname("; then
|
||||
if printf '%s\n' "$requires" | grep -F "$soname(" >/dev/null; then
|
||||
unsatisfiable="$unsatisfiable $soname"
|
||||
fi
|
||||
done
|
||||
@@ -251,7 +270,7 @@ fi
|
||||
# 2. The provides exclusion still doing its job. A package advertising libcoreclr.so to the whole
|
||||
# system is the thing that exclusion exists to prevent, and a future rpm that stopped honouring
|
||||
# the macro would break it silently and in the direction nobody looks.
|
||||
if printf '%s\n' "$provides" | grep -qE '^lib.*\.so'; then
|
||||
if printf '%s\n' "$provides" | grep -E '^lib.*\.so' >/dev/null; then
|
||||
echo "This package advertises bundled libraries to the system:" >&2
|
||||
printf '%s\n' "$provides" | grep -E '^lib.*\.so' >&2
|
||||
echo "__provides_exclude_from in build/linux/dodossh.spec is meant to stop that." >&2
|
||||
@@ -262,7 +281,7 @@ fi
|
||||
# without libelf would do exactly that — the package would carry no glibc requirement at all and
|
||||
# would install cheerfully onto a host too old to run it. libc.so.6 is the one dependency a
|
||||
# self-contained .NET tree cannot fail to have.
|
||||
if ! printf '%s\n' "$requires" | grep -q '^libc\.so\.6'; then
|
||||
if ! printf '%s\n' "$requires" | grep '^libc\.so\.6' >/dev/null; then
|
||||
echo "This package does not require libc, which a self-contained .NET tree cannot be true of." >&2
|
||||
echo "The automatic ELF scan produced nothing; rpm here is probably built without elfdeps." >&2
|
||||
printf '%s\n' "$requires" >&2
|
||||
@@ -279,9 +298,9 @@ for expected in \
|
||||
"/usr/share/icons/hicolor/256x256/apps/$bin.png" \
|
||||
"/usr/lib/udev/rules.d/71-dodossh-tpm.rules"
|
||||
do
|
||||
if ! printf '%s\n' "$files" | grep -qxF "$expected"; then
|
||||
if ! printf '%s\n' "$files" | grep -xF "$expected" >/dev/null; then
|
||||
echo "The package was built and $expected is not in it." >&2
|
||||
printf '%s\n' "$files" | head -40 >&2
|
||||
printf '%s\n' "$files" | awk 'NR<=40' >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
+60
-10
@@ -169,16 +169,66 @@ Bump ApplicationVersion, commit, move the tag, and run this again.
|
||||
if (Test-Path $Staging) { Remove-Item -Recurse -Force $Staging }
|
||||
New-Item -ItemType Directory -Path $Staging | Out-Null
|
||||
|
||||
# No RuntimeIdentifier, so every supported ABI is packaged. An arm64-only APK will not install on a
|
||||
# 32-bit handset or an x86_64 emulator, and this is the package strangers are pointed at.
|
||||
& dotnet build $Project --no-restore --configuration Release `
|
||||
-t:SignAndroidPackage `
|
||||
-p:DodoChannel=release `
|
||||
-p:AndroidKeyStore=true `
|
||||
-p:AndroidSigningKeyStore=$KeystorePath `
|
||||
-p:AndroidSigningKeyAlias=$KeyAlias `
|
||||
-p:AndroidSigningStorePass=$storePlain `
|
||||
-p:AndroidSigningKeyPass=$keyPlain
|
||||
# ◆ THE PASSWORDS GO THROUGH THE ENVIRONMENT, BECAUSE -p: PUTS THEM ON A COMMAND LINE.
|
||||
#
|
||||
# They used to be written straight into -p:AndroidSigningStorePass=..., which reads as private and is
|
||||
# not. A process's command line is not a secret on Windows: any process running as this user can read
|
||||
# every argument of every other with one call —
|
||||
#
|
||||
# Get-CimInstance Win32_Process | Select-Object Name, CommandLine
|
||||
#
|
||||
# — and process-creation auditing, EDR agents and crash dumps all record it as a matter of course. The
|
||||
# comment above says the passwords are never stored, and with the arguments written that way it was
|
||||
# true of the disk and false of everything watching the machine.
|
||||
#
|
||||
# env: is .NET for Android's own answer to exactly this, and it names the variable rather than carrying
|
||||
# the value: MSBuild is handed the string "env:DODOSSH_ANDROID_STOREPASS" and reads the password out of
|
||||
# the environment itself. The one restriction is $(AndroidPackageFormat) = aab, where env: is not
|
||||
# supported and file: is the documented alternative; this channel ships an APK, and the guard below
|
||||
# fails loudly rather than silently signing with the literal string if that ever changes.
|
||||
#
|
||||
# Not a perfect channel — an environment block is readable by a process with rights over this one, the
|
||||
# same as memory is — but it is off the command line, out of the audit trail and out of any log that
|
||||
# echoes the build invocation, which is the whole of the difference being bought here.
|
||||
#
|
||||
# -nodeReuse:false so no MSBuild worker outlives this call holding the variables in its environment.
|
||||
# The finally clears them from this process either way, and a persistent node would have kept its own
|
||||
# inherited copy for the next quarter of an hour regardless of what happens here.
|
||||
$packageFormat = (& dotnet msbuild $Project -getProperty:AndroidPackageFormat -nologo `
|
||||
-p:DodoChannel=release | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||
|
||||
if ($packageFormat -eq 'aab') {
|
||||
Stop-With @'
|
||||
This channel now builds an aab, and the env: prefix this script signs with is not supported for that
|
||||
format. The signing would silently take "env:DODOSSH_ANDROID_STOREPASS" for the password itself.
|
||||
|
||||
Switch both passwords to the file: prefix, which is supported for aab, and make sure whatever writes
|
||||
that file removes it again.
|
||||
'@
|
||||
}
|
||||
|
||||
try {
|
||||
$env:DODOSSH_ANDROID_STOREPASS = $storePlain
|
||||
$env:DODOSSH_ANDROID_KEYPASS = $keyPlain
|
||||
|
||||
# No RuntimeIdentifier, so every supported ABI is packaged. An arm64-only APK will not install on a
|
||||
# 32-bit handset or an x86_64 emulator, and this is the package strangers are pointed at.
|
||||
& dotnet build $Project --no-restore --configuration Release `
|
||||
-nodeReuse:false `
|
||||
-t:SignAndroidPackage `
|
||||
-p:DodoChannel=release `
|
||||
-p:AndroidKeyStore=true `
|
||||
-p:AndroidSigningKeyStore=$KeystorePath `
|
||||
-p:AndroidSigningKeyAlias=$KeyAlias `
|
||||
-p:AndroidSigningStorePass=env:DODOSSH_ANDROID_STOREPASS `
|
||||
-p:AndroidSigningKeyPass=env:DODOSSH_ANDROID_KEYPASS
|
||||
}
|
||||
finally {
|
||||
# SetEnvironmentVariable rather than Remove-Item, which throws when the variable is not there —
|
||||
# and this runs on the failure path, where it may well not be.
|
||||
[Environment]::SetEnvironmentVariable('DODOSSH_ANDROID_STOREPASS', $null)
|
||||
[Environment]::SetEnvironmentVariable('DODOSSH_ANDROID_KEYPASS', $null)
|
||||
}
|
||||
|
||||
if ($LASTEXITCODE -ne 0) { Stop-With 'The build failed.' }
|
||||
|
||||
|
||||
@@ -38,9 +38,11 @@
|
||||
# xcrun notarytool store-credentials DodoSSH \
|
||||
# --apple-id you@example.com --team-id TEAMID --password <app-specific-password>
|
||||
#
|
||||
# The forge token is the one real secret, and it is prompted for rather than read from a file or the
|
||||
# environment, and only in the phase that needs it — for the reason the Windows script gives: the fewer
|
||||
# minutes a credential that can publish an update spends in a shell's memory the better.
|
||||
# The forge token is the one real secret, and it is prompted for rather than read from a file or an
|
||||
# ambient environment variable, and only in the phase that needs it — for the reason the Windows script
|
||||
# gives: the fewer minutes a credential that can publish an update spends in a shell's memory the better.
|
||||
# It reaches vpk through VPK_TOKEN, exported for that one call and unset afterwards, because the
|
||||
# alternative is --token on a command line that is not private. See the comment at the upload itself.
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/release-macos.sh
|
||||
@@ -195,10 +197,18 @@ if [ "$UPLOAD" -eq 1 ]; then
|
||||
# also already hold the Windows package for the same tag, which is the case --merge is really doing
|
||||
# the work for: without it the second platform to publish a given version fails on a release that
|
||||
# exists, and with it the two sit side by side under one tag. --channel keeps the indexes apart.
|
||||
#
|
||||
# No --token in this array, and that is deliberate. It used to be there, and an argument is not a
|
||||
# private place to put a credential: /proc/<pid>/cmdline is world-readable on Linux, `ps` hands out
|
||||
# other processes' arguments freely, and whatever agent is auditing process creation on this machine
|
||||
# keeps its own copy. The comment above is about shell history and was never the whole story.
|
||||
#
|
||||
# vpk documents VPK_TOKEN as --token's environment fallback, so the value never becomes an argument.
|
||||
# Exported for the one call and unset straight after; an environment block is still readable by a
|
||||
# process with rights over this one, but it is out of every process listing and audit log.
|
||||
UPLOAD_ARGS=(
|
||||
upload gitea
|
||||
--repoUrl "$REPO_URL"
|
||||
--token "$TOKEN"
|
||||
--outputDir "$RELEASES_DIR"
|
||||
--channel "$CHANNEL"
|
||||
--releaseName "$TAG"
|
||||
@@ -214,7 +224,15 @@ if [ "$UPLOAD" -eq 1 ]; then
|
||||
esac
|
||||
|
||||
write_step 'Uploading'
|
||||
dotnet vpk "${UPLOAD_ARGS[@]}" || stop_with 'vpk upload failed.'
|
||||
|
||||
# Unset on the way out whether or not the upload worked, so a failed release does not leave the token
|
||||
# sitting in this shell for whatever runs next.
|
||||
export VPK_TOKEN="$TOKEN"
|
||||
vpk_status=0
|
||||
dotnet vpk "${UPLOAD_ARGS[@]}" || vpk_status=$?
|
||||
unset VPK_TOKEN
|
||||
|
||||
[ "$vpk_status" -eq 0 ] || stop_with 'vpk upload failed.'
|
||||
|
||||
write_step "Published $TAG."
|
||||
exit 0
|
||||
|
||||
@@ -16,9 +16,11 @@
|
||||
2. With -Upload: asks for the forge token and publishes what phase 1 produced. It does not rebuild,
|
||||
so the bytes that reach users are the bytes that were installed and checked.
|
||||
|
||||
The token is prompted for rather than read from a file or an environment variable, and only in the phase
|
||||
that needs it — the build does not, and the fewer minutes a credential that can publish an update spends
|
||||
in a shell's memory the better.
|
||||
The token is prompted for rather than read from a file or an ambient environment variable, and only in
|
||||
the phase that needs it — the build does not, and the fewer minutes a credential that can publish an
|
||||
update spends in a shell's memory the better. It reaches vpk through VPK_TOKEN, set for that one call
|
||||
and cleared afterwards, because the alternative is --token on a command line every process on this
|
||||
machine can read. See the comment at the upload itself.
|
||||
|
||||
.PARAMETER Upload
|
||||
Publish the packages already in Releases/ instead of building.
|
||||
@@ -152,10 +154,24 @@ try {
|
||||
# --merge because Gitea already has a release entry for the pushed tag, and without it the upload
|
||||
# fails on a release that exists. --pre mirrors the rule the docker image job already applies to the
|
||||
# same tag, so a release candidate is a prerelease in both channels or in neither.
|
||||
#
|
||||
# ◆ NO --token HERE. IT WAS AN ARGUMENT, AND AN ARGUMENT IS PUBLIC ON THIS MACHINE.
|
||||
#
|
||||
# The comment above says the token never lands in shell history, which was true and was not the
|
||||
# whole story: it went straight into vpk's command line, and any process running as this user can
|
||||
# read that off a running process with one call —
|
||||
#
|
||||
# Get-CimInstance Win32_Process | Select-Object Name, CommandLine
|
||||
#
|
||||
# — with process-creation auditing and EDR agents keeping their own copy afterwards. A token that
|
||||
# can publish an update every installed copy will fetch and run deserves better than that.
|
||||
#
|
||||
# vpk documents VPK_TOKEN as --token's environment fallback, so the flag simply goes away and the
|
||||
# value never becomes an argument at all. An environment block is still readable by a process with
|
||||
# rights over this one; what it is not is a string sitting in a process listing and an audit log.
|
||||
$uploadArgs = @(
|
||||
'upload', 'gitea',
|
||||
'--repoUrl', $RepoUrl,
|
||||
'--token', $token,
|
||||
'--outputDir', $ReleasesDir,
|
||||
'--channel', $Channel,
|
||||
'--releaseName', $tag,
|
||||
@@ -169,7 +185,17 @@ try {
|
||||
}
|
||||
|
||||
Write-Step 'Uploading'
|
||||
& dotnet vpk @uploadArgs
|
||||
|
||||
try {
|
||||
$env:VPK_TOKEN = $token
|
||||
& dotnet vpk @uploadArgs
|
||||
}
|
||||
finally {
|
||||
# SetEnvironmentVariable rather than Remove-Item, which throws when the variable is not there —
|
||||
# and this runs on the failure path, where it may well not be.
|
||||
[Environment]::SetEnvironmentVariable('VPK_TOKEN', $null)
|
||||
}
|
||||
|
||||
if ($LASTEXITCODE -ne 0) { Stop-With 'vpk upload failed.' }
|
||||
|
||||
Write-Step "Published $tag."
|
||||
|
||||
@@ -115,10 +115,20 @@ public sealed class RelayOptions
|
||||
/// Whether RFC1918 and other private ranges may be dialled.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Defaults to true, unlike a typical SSRF allow-list, because reaching private
|
||||
/// infrastructure is the entire purpose of a self-hosted SSH tool. The control that matters is
|
||||
/// the ACL: only hosts in a vault the caller holds Connect on can be dialled at all. Loopback,
|
||||
/// link-local and cloud metadata ranges are denied unconditionally and are not configurable.
|
||||
/// the ACL: only hosts in a vault the caller holds Connect on can be dialled at all.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Nothing reads this setting yet, and that is the point of saying so here.</b> The relay is
|
||||
/// an ADR and a contract; there is no endpoint and no dialler. This option exists so the shape
|
||||
/// is settled before the code is, which makes it exactly the kind of comment that gets read as a
|
||||
/// description of an implemented control. It is not one. Whoever builds the dialler owes ADR 0004
|
||||
/// §3 in full — loopback, link-local, <c>0.0.0.0/8</c>, multicast and the cloud metadata addresses
|
||||
/// denied with no configuration escape, and IPv4-mapped IPv6 normalised and re-checked — and this
|
||||
/// flag governs RFC1918 only, never that list.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public bool AllowPrivateNetworks { get; set; } = true;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Input.Platform;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
using DodoSSH.Client.App.Platform;
|
||||
using DodoSSH.Client.App.Views;
|
||||
using DodoSSH.Client.Auth;
|
||||
@@ -33,12 +34,77 @@ internal sealed partial class DodoSshApp : Application
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
Compose(desktop);
|
||||
ShowSomethingWhileComposing(desktop);
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Puts a window on screen first, and composes the application behind it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The splash <em>is</em> the main window for a moment, which is what makes this work at all.</b> The
|
||||
/// desktop lifetime shows whatever <c>MainWindow</c> holds once this method returns and then runs the
|
||||
/// message loop; a window created here and merely shown would be a window with no lifetime attached, and
|
||||
/// with <c>ShutdownMode.OnLastWindowClose</c> the first close of anything would end the process. Handing
|
||||
/// the property over is one assignment, and Avalonia reads it lazily.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>And composition is posted rather than called.</b> It is synchronous and takes long enough to be
|
||||
/// worth a splash in the first place — which is the whole argument for this method — so running it here
|
||||
/// would block the very dispatcher turn that has to paint the thing. Background priority puts it behind
|
||||
/// rendering, which is what makes this a painted window rather than merely a shown one.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static void ShowSomethingWhileComposing(IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
var splash = new SplashWindow();
|
||||
|
||||
desktop.MainWindow = splash;
|
||||
|
||||
// Fully qualified, and it has to be: Application inherits an instance Dispatcher property from
|
||||
// AvaloniaObject, member lookup beats the namespace, and a static method here cannot reach an
|
||||
// instance property — so the unqualified name is a compile error rather than the type. Same below.
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(
|
||||
() => ComposeBehind(desktop, splash),
|
||||
DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The order at the end is not interchangeable. <c>ShutdownMode</c> is <c>OnLastWindowClose</c>, so the
|
||||
/// real window is shown while the splash is still open and the splash is closed afterwards; closing the
|
||||
/// only window there is would end the process instead of handing it over.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A failure is caught rather than left to the dispatcher, and the splash is exactly why.</b> Before
|
||||
/// this method existed a composition that threw took the process with it and left nothing behind. Now
|
||||
/// there is something to leave behind — so an exception escaping a posted callback would abandon a window
|
||||
/// saying "Starting…" over a process that had stopped, which is worse than the crash it replaced. What
|
||||
/// arrives instead is the sentence and a way to close it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static void ComposeBehind(
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
SplashWindow splash)
|
||||
{
|
||||
try
|
||||
{
|
||||
Compose(desktop);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
splash.CouldNotStart(exception.Message);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
desktop.MainWindow?.Show();
|
||||
splash.Close();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Composed by hand rather than through a container. The graph is a handful of objects deep and an
|
||||
@@ -137,12 +203,44 @@ internal sealed partial class DodoSshApp : Application
|
||||
|
||||
desktop.MainWindow = new MainWindow { DataContext = viewModel };
|
||||
|
||||
// The other half of Program's claim on this profile: a second launch was refused, and this is what
|
||||
// answers it. Posted onto the UI thread because a pipe accept completes on a thread pool thread and
|
||||
// a window may only be touched from the thread that owns it — see SingleInstance.Listen.
|
||||
var others = SingleInstance.Listen(
|
||||
paths,
|
||||
() => Avalonia.Threading.Dispatcher.UIThread.Post(() => BringToFront(desktop.MainWindow)));
|
||||
|
||||
// Started rather than awaited: the framework's initialisation must not block on a schema
|
||||
// migration. The view model shows its own progress and handles its own failures, which is why
|
||||
// discarding the task here is safe rather than merely convenient.
|
||||
_ = viewModel.StartAsync(CancellationToken.None);
|
||||
|
||||
WireShutdown(desktop, viewModel, workspace, caches);
|
||||
WireShutdown(desktop, viewModel, workspace, caches, others);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raises the window somebody has just asked for by launching the application again.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Un-minimised first, because activating a minimised window leaves it minimised and the launch would
|
||||
/// look ignored. <c>Activate</c> is a request rather than a guarantee — Windows refuses the foreground to
|
||||
/// a process that did not receive the input, and what happens then is a flashing taskbar button, which is
|
||||
/// still an answer. That is the platform's own rule about focus stealing and not a thing to work around.
|
||||
/// </remarks>
|
||||
private static void BringToFront(Window? window)
|
||||
{
|
||||
if (window is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.WindowState is WindowState.Minimized)
|
||||
{
|
||||
window.WindowState = WindowState.Normal;
|
||||
}
|
||||
|
||||
window.Show();
|
||||
window.Activate();
|
||||
}
|
||||
|
||||
/// <summary>Composes the shell itself, with everything this platform answers for it.</summary>
|
||||
@@ -271,7 +369,8 @@ internal sealed partial class DodoSshApp : Application
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
MainWindowViewModel viewModel,
|
||||
TerminalWorkspace workspace,
|
||||
ClientCacheFactory caches)
|
||||
ClientCacheFactory caches,
|
||||
IAsyncDisposable others)
|
||||
{
|
||||
var shuttingDown = false;
|
||||
|
||||
@@ -290,6 +389,11 @@ internal sealed partial class DodoSshApp : Application
|
||||
await viewModel.DisposeAsync().ConfigureAwait(true);
|
||||
await workspace.DisposeAsync().ConfigureAwait(true);
|
||||
|
||||
// Awaited rather than dropped, so the pipe name is free before this process is. It matters for
|
||||
// one caller and matters a lot there: an update relaunches this application, and a successor
|
||||
// that found the name still taken would be a second instance refusing itself.
|
||||
await others.DisposeAsync().ConfigureAwait(true);
|
||||
|
||||
caches.Dispose();
|
||||
|
||||
desktop.Shutdown();
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
using System.IO.Pipes;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using DodoSSH.Client.Session;
|
||||
|
||||
namespace DodoSSH.Client.App.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// One desktop instance per profile, and a way for a second launch to raise the first.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Per profile rather than per machine, which decides two things at once.</b> The lock lives in
|
||||
/// <see cref="ClientPaths.InstanceLockFile"/>, so a nightly and a release build never exclude each other —
|
||||
/// they are separate applications by design, ADR 0013 — and two people signed in to one machine each get
|
||||
/// their own instance. A global mutex would have had to re-derive both of those from somewhere.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Why one instance at all.</b> The cache is a SQLite file with an outbox in it, and the whole design
|
||||
/// assumes one writer: <see cref="ClientPaths"/> says so about two machines sharing a profile through a
|
||||
/// cloud sync client, and two processes on one machine is the same hazard with a shorter path to it. The
|
||||
/// terminal's loopback listener is the visible half — the second process fails to bind, gets a port of its
|
||||
/// own or refuses to start a workspace at all — and the cache is the half that would quietly corrupt.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A file handle rather than a named mutex</b>, because the operating system releases it when the
|
||||
/// process ends <em>however</em> it ends. A named mutex abandoned by a killed process is a state both
|
||||
/// platforms model differently and neither models simply; an exclusive handle is closed by the kernel on
|
||||
/// Windows and by the file descriptor going away on Unix, so there is no such thing as a stale lock here.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>And a named pipe for the other half, because exiting silently is not an answer.</b> Somebody who
|
||||
/// double-clicks the icon and sees nothing happen has been told the application is broken. The second
|
||||
/// process asks the first to come forward and then leaves, which is what every desktop application does and
|
||||
/// what makes the exclusion invisible rather than mysterious.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class SingleInstance
|
||||
{
|
||||
/// <summary>The one thing a second launch has to say.</summary>
|
||||
/// <remarks>
|
||||
/// Compared against a constant rather than parsed, for the reason <c>MainWindow</c>'s release-focus
|
||||
/// message is: one message exists and treating anything else as a command would be a wider door than
|
||||
/// this needs. Anything may connect to a per-user pipe — it is not a security boundary — so what arrives
|
||||
/// is checked rather than trusted.
|
||||
/// </remarks>
|
||||
private const string ComeForward = "dodossh.come-forward";
|
||||
|
||||
/// <summary>How long a second launch waits for the first to answer its pipe.</summary>
|
||||
/// <remarks>
|
||||
/// Short, because the pipe is local and the process on the other end is either listening or not. It is
|
||||
/// not a bound on anything slow; it is how long to wait before concluding that whoever holds the lock
|
||||
/// cannot be talked to.
|
||||
/// </remarks>
|
||||
private const int AnswerTimeoutMilliseconds = 500;
|
||||
|
||||
/// <summary>How long to keep trying for a lock that is held by nobody who answers.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>This is the window an update relaunch lands in, and without it an update would look like a
|
||||
/// crash.</b> Velopack's apply ends this process and its updater then starts the new build; the two are
|
||||
/// ordered — the updater waits on the old process id — but the whole sequence is one where a lock held a
|
||||
/// moment too long would make the relaunched application exit on the spot, having apparently uninstalled
|
||||
/// itself. The same window covers an ordinary double-double-click, where the first process holds the file
|
||||
/// and has not reached its pipe yet.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only reached when nobody answers the pipe. A live instance replies in milliseconds and the second
|
||||
/// launch is gone before any of this matters.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan DefaultPatience = TimeSpan.FromSeconds(3);
|
||||
|
||||
/// <summary>How long to wait between attempts within that window.</summary>
|
||||
private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
/// <summary>
|
||||
/// Claims this profile for this process, or asks whoever holds it to come forward.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// A handle to hold for the life of the process, or <see langword="null"/> when another instance has it —
|
||||
/// in which case that instance has already been asked to raise its window and this process should end.
|
||||
/// </returns>
|
||||
/// <param name="paths">The profile to claim. Its directory is created if it is not there.</param>
|
||||
/// <param name="patience">
|
||||
/// How long to keep trying a lock nobody answers for, or null for <see cref="DefaultPatience"/>. Named
|
||||
/// by a test so the give-up path does not cost the suite three seconds; the application never passes it.
|
||||
/// </param>
|
||||
internal static IDisposable? Claim(ClientPaths paths, TimeSpan? patience = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
|
||||
try
|
||||
{
|
||||
paths.EnsureCreated();
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// No profile directory means no lock file, and every build before this one started anyway. What
|
||||
// follows will fail on its own terms and say something better than this could.
|
||||
return NoClaim.Instance;
|
||||
}
|
||||
|
||||
// Counted rather than clocked. Thread.Sleep is not a clock and reading one here would be pretending
|
||||
// otherwise — what this bounds is the number of attempts, and the wall time is whatever the platform
|
||||
// makes of them.
|
||||
var attemptsLeft = (int)Math.Ceiling(
|
||||
(patience ?? DefaultPatience).TotalMilliseconds / RetryDelay.TotalMilliseconds);
|
||||
|
||||
while (true)
|
||||
{
|
||||
if (TryTake(paths.InstanceLockFile) is { } taken)
|
||||
{
|
||||
return taken;
|
||||
}
|
||||
|
||||
// Asked before waiting, so the ordinary case — an instance that is up and running — costs one
|
||||
// round trip on a local pipe rather than the whole window below.
|
||||
if (AskTheRunningOneToComeForward(paths))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (attemptsLeft-- <= 0)
|
||||
{
|
||||
// Held by something that will not talk. Giving up rather than starting anyway, because what
|
||||
// is being protected is a database with one writer, and "probably nobody" is not an answer
|
||||
// worth corrupting it for.
|
||||
return null;
|
||||
}
|
||||
|
||||
Thread.Sleep(RetryDelay);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Answers the launches that were refused, for as long as the returned handle is held.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <paramref name="comeForward"/> is invoked on a thread pool thread, because that is where a pipe
|
||||
/// accept completes. A caller that touches a window has to marshal.
|
||||
/// </remarks>
|
||||
/// <param name="paths">The profile this instance claimed.</param>
|
||||
/// <param name="comeForward">What to do when another launch asks for the window.</param>
|
||||
internal static IAsyncDisposable Listen(ClientPaths paths, Action comeForward)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
ArgumentNullException.ThrowIfNull(comeForward);
|
||||
|
||||
var listener = new Doorbell(PipeNameFor(paths), comeForward);
|
||||
|
||||
listener.Start();
|
||||
|
||||
return listener;
|
||||
}
|
||||
|
||||
/// <summary>Tells the instance that holds this profile to raise its window.</summary>
|
||||
/// <returns>Whether anything was listening.</returns>
|
||||
/// <remarks>
|
||||
/// Internal rather than private because it is the half a test can drive: the exclusion is a file handle
|
||||
/// and proves itself, and this is the part with a protocol in it.
|
||||
/// </remarks>
|
||||
internal static bool AskTheRunningOneToComeForward(ClientPaths paths)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
|
||||
try
|
||||
{
|
||||
using var pipe = new NamedPipeClientStream(
|
||||
serverName: ".",
|
||||
PipeNameFor(paths),
|
||||
PipeDirection.Out);
|
||||
|
||||
pipe.Connect(AnswerTimeoutMilliseconds);
|
||||
|
||||
using var writer = new StreamWriter(pipe, Encoding.UTF8) { AutoFlush = true };
|
||||
|
||||
writer.WriteLine(ComeForward);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception)
|
||||
when (exception is TimeoutException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Nothing there, or nothing willing. The caller decides what that means; here it is only ever
|
||||
// "no".
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Takes the lock, or answers null if somebody else has it.</summary>
|
||||
/// <remarks>
|
||||
/// The file is left in place rather than deleted on close. <c>FileOptions.DeleteOnClose</c> looks
|
||||
/// tidier and is worse: it makes the file's existence part of the protocol, so a second process opening
|
||||
/// it while the first is closing gets a handle to something on its way out. An empty file that is always
|
||||
/// there and sometimes locked has one fewer state.
|
||||
/// </remarks>
|
||||
private static IDisposable? TryTake(string lockFile)
|
||||
{
|
||||
try
|
||||
{
|
||||
return new FileStream(
|
||||
lockFile,
|
||||
FileMode.OpenOrCreate,
|
||||
FileAccess.ReadWrite,
|
||||
FileShare.None,
|
||||
bufferSize: 1,
|
||||
FileOptions.None);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Somebody else holds it. The one expected failure, and the whole point.
|
||||
return null;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
// A profile this user cannot write to, which is a broken installation rather than a second
|
||||
// instance. Answered as a claim so the application starts and fails on something legible.
|
||||
return NoClaim.Instance;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The pipe both sides name, derived from the profile so that it is scoped exactly as the lock is.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A digest of the path rather than the path itself: a pipe name may not contain a directory separator
|
||||
/// on Windows, and on Unix the name becomes a file in a temporary directory whose length is bounded.
|
||||
/// Eight bytes of SHA-256, which is not doing security work — it is making one string out of another
|
||||
/// deterministically, and <c>string.GetHashCode</c> cannot because it is randomised per process.
|
||||
/// </remarks>
|
||||
private static string PipeNameFor(ClientPaths paths)
|
||||
{
|
||||
var digest = SHA256.HashData(Encoding.UTF8.GetBytes(paths.DataDirectory));
|
||||
|
||||
return "dodossh-" + Convert.ToHexStringLower(digest.AsSpan(0, 8));
|
||||
}
|
||||
|
||||
/// <summary>A claim over nothing, for the cases where there was nothing to claim.</summary>
|
||||
private sealed class NoClaim : IDisposable
|
||||
{
|
||||
internal static NoClaim Instance { get; } = new();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Nothing was taken.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The accept loop that answers refused launches.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One server instance for the life of the doorbell, disconnected between callers rather than disposed
|
||||
/// and rebuilt. The loop is what makes a third and fourth launch work as well as the second.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>THE ONE INSTANCE IS THE WHOLE CORRECTNESS OF THIS CLASS ON LINUX, AND A FRESH ONE PER CALLER
|
||||
/// LOSES LAUNCHES.</b> This was written the obvious way — a <c>using</c> inside the loop, a new stream
|
||||
/// per connection — which is fine on Windows and quietly wrong on Unix, where a named pipe is a Unix
|
||||
/// domain socket. Disposing the server there closes the <em>listening</em> socket and unlinks its path,
|
||||
/// so a second launch that connected while the first was being served has its connection dropped on the
|
||||
/// floor: its <c>Connect</c> returned, its write succeeded into a buffer, and nobody ever read it. The
|
||||
/// launch was told it had rung the doorbell and the window never came forward.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Measured rather than reasoned about, in a container, because the two platforms disagree and this
|
||||
/// machine is only one of them: three rings back to back are answered three times with one instance and
|
||||
/// once with a fresh one per connection. <c>Disconnect</c> ends the conversation and keeps the socket, so
|
||||
/// there is never a moment with nothing listening.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Which makes <c>leaveOpen</c> on the reader load-bearing as well. A <see cref="StreamReader"/> disposes
|
||||
/// the stream it was given, and the stream is now the server that has to survive into the next
|
||||
/// iteration — so the reader that was harmless per-connection would close the doorbell after one ring.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private sealed class Doorbell : IAsyncDisposable
|
||||
{
|
||||
private readonly string name;
|
||||
private readonly Action comeForward;
|
||||
private readonly CancellationTokenSource lifetime = new();
|
||||
|
||||
private Task? loop;
|
||||
|
||||
internal Doorbell(string name, Action comeForward)
|
||||
{
|
||||
this.name = name;
|
||||
this.comeForward = comeForward;
|
||||
}
|
||||
|
||||
internal void Start() => loop = RunAsync(lifetime.Token);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await lifetime.CancelAsync().ConfigureAwait(false);
|
||||
|
||||
if (loop is { } running)
|
||||
{
|
||||
// Awaited rather than abandoned, so the pipe name is free by the time this returns — which
|
||||
// matters for exactly one caller: an update that is about to relaunch this application and
|
||||
// would otherwise race its own successor for the name.
|
||||
await running.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
lifetime.Dispose();
|
||||
}
|
||||
|
||||
private async Task RunAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var server = new NamedPipeServerStream(
|
||||
name,
|
||||
PipeDirection.In,
|
||||
maxNumberOfServerInstances: 1,
|
||||
PipeTransmissionMode.Byte,
|
||||
PipeOptions.Asynchronous);
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// leaveOpen, because this server outlives the conversation. See the remark above.
|
||||
using (var reader = new StreamReader(server, Encoding.UTF8, false, 1024, leaveOpen: true))
|
||||
{
|
||||
var said = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (string.Equals(said, ComeForward, StringComparison.Ordinal))
|
||||
{
|
||||
comeForward();
|
||||
}
|
||||
}
|
||||
|
||||
HangUp(server);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// A client that connected and hung up mid-sentence. Round again: giving up here would
|
||||
// leave every later launch with nothing to talk to, which is the silent-exit behaviour
|
||||
// this class exists to avoid.
|
||||
HangUp(server);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ends one conversation without ending the doorbell.</summary>
|
||||
/// <remarks>
|
||||
/// Guarded, because <c>Disconnect</c> throws on a server that is not connected — which is the state
|
||||
/// an <see cref="IOException"/> above may already have left it in, and the state a second call would
|
||||
/// find. Throwing out of the error path would take the loop with it.
|
||||
/// </remarks>
|
||||
private static void HangUp(NamedPipeServerStream server)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (server.IsConnected)
|
||||
{
|
||||
server.Disconnect();
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Already disconnected. Nothing to end.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,27 @@ internal static class Program
|
||||
|
||||
KeepTheWebViewProfileOutOfTheInstallDirectory();
|
||||
|
||||
/*
|
||||
One instance per profile, and nothing above this line is affected by it: the installer's own runs
|
||||
have already exited inside VelopackApp.Run, and the WebView2 environment variable is a property of
|
||||
this process rather than of the application.
|
||||
|
||||
Claimed here rather than after Avalonia is configured, so that a second launch starts nothing — no
|
||||
toolkit, no window, no splash flashing on screen before it thinks better of it. What it does
|
||||
instead is ask the running instance to come forward, which is why this is not simply an exit.
|
||||
|
||||
The handle is held for as long as Main runs, which is as long as the message loop does. See
|
||||
SingleInstance for why it is a file rather than a mutex, and for what the profile scoping buys.
|
||||
*/
|
||||
var paths = ClientPaths.ForChannel(DesktopChannel.Name);
|
||||
|
||||
using var claim = SingleInstance.Claim(paths);
|
||||
|
||||
if (claim is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
}
|
||||
|
||||
|
||||
@@ -111,13 +111,33 @@
|
||||
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="18" MaxWidth="460"
|
||||
Margin="24">
|
||||
|
||||
<!--
|
||||
Which machine, and at what address. Both selectable, for the reason the session column's own address
|
||||
is — see SessionSidebar.axaml: a TextBlock in Avalonia is a drawing of a string rather than a string,
|
||||
and this pair is the one place the shell says what it is dialling *before* anything has answered.
|
||||
That is exactly when the value is wanted somewhere else: in a ticket, in a message to whoever owns
|
||||
the host, in the ssh command being written in the window beside this one. It is also the moment it is
|
||||
least available anywhere else, because there is no prompt yet and may never be one — a refused
|
||||
connection leaves this card holding the only copy on screen.
|
||||
|
||||
Both rather than only the name. They are two lines of one identifier, and a pair where the first can
|
||||
be taken and the second cannot is a worse answer than either.
|
||||
|
||||
The trimming stays, and the tooltips are what make it safe rather than a trap. A drag reaches only
|
||||
the glyphs that were drawn, so a name or an address wearing an ellipsis has to be taken with
|
||||
select-all — Ctrl+A, then Ctrl+C — which takes the whole value, because the ellipsis is a drawing and
|
||||
not a shorter string. The hover says what the whole value is, so nobody copies half of one without
|
||||
having been told there is a half. Same argument, same shape, as the sidebar's.
|
||||
-->
|
||||
<StackPanel Spacing="6" HorizontalAlignment="Center">
|
||||
<TextBlock Classes="mono" Text="{Binding SelectedTab.Label}" FontSize="16" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" HorizontalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="mono" Text="{Binding SelectedTab.Address}" FontSize="11.5"
|
||||
Foreground="{StaticResource TextFaint}" HorizontalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding SelectedTab.Label}" FontSize="16"
|
||||
FontWeight="Medium" Foreground="{StaticResource Text}"
|
||||
HorizontalAlignment="Center" TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding SelectedTab.Label}" />
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding SelectedTab.Address}" FontSize="11.5"
|
||||
Foreground="{StaticResource TextFaint}" HorizontalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding SelectedTab.Address}" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="DodoSSH.Client.App.Views.SplashCard">
|
||||
|
||||
<!--
|
||||
What is on screen while the application composes itself.
|
||||
|
||||
── WHY THIS IS ITS OWN CONTROL ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
The same reason UpdateBanner is: a Window cannot be laid out by the headless suite when it contains a
|
||||
WebView, and while this one does not, a UserControl is what LayoutHarness can host and measure. See
|
||||
SplashCardTests. It also keeps the window itself down to a frame and a size.
|
||||
|
||||
── AND WHY THERE IS NO VIEW MODEL ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
There is nothing to observe. Composition is one synchronous block — see DodoSshApp.ComposeBehind — so
|
||||
this control has exactly two states, both of them set by the one caller: starting, and could not. Two
|
||||
methods on the code-behind is the whole of it, and a view model would be an observable object with a
|
||||
lifetime and a test of its own standing in for two assignments.
|
||||
-->
|
||||
<Border Background="{StaticResource Chrome}"
|
||||
BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1">
|
||||
|
||||
<StackPanel Margin="28" Spacing="12" VerticalAlignment="Center">
|
||||
|
||||
<TextBlock Classes="heading" Text="DodoSSH" />
|
||||
|
||||
<TextBlock x:Name="Line" Classes="hint" Text="Starting…" />
|
||||
|
||||
<!--
|
||||
── AND NO PROGRESS BAR, WHICH IS A REMOVAL RATHER THAN AN OMISSION ─────────────────────────────────
|
||||
|
||||
There was an indeterminate one here, and on screen it did not move. The reason is the same fact this
|
||||
whole window exists for: composition is a synchronous block, and DodoSshApp posts it to the very
|
||||
dispatcher that would have to animate the bar. Background priority buys the splash its first paint
|
||||
and nothing after it — once composition starts, the UI thread is inside it until the main window is
|
||||
ready, and an indeterminate bar's animation is driven by that thread's clock. So it painted one
|
||||
frame and froze.
|
||||
|
||||
A frozen indeterminate bar does not read as "indeterminate". It reads as a determinate bar stuck at
|
||||
zero, which is the one thing worse than no bar at all: it says the work has stalled at the exact
|
||||
moment the application is busiest. Nothing here can honestly be measured either — a SQLite open, a
|
||||
loopback bind, a TPM probe and an install-layout read report no progress between them.
|
||||
|
||||
So the line above is the whole of it. "Starting…" is true for as long as it is on screen, it needs
|
||||
no clock to stay true, and the window closing is what says it finished.
|
||||
-->
|
||||
|
||||
</StackPanel>
|
||||
|
||||
</Border>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,29 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>What is on screen while the application composes itself.</summary>
|
||||
/// <remarks>
|
||||
/// The two methods below are the whole interface, and both are called by
|
||||
/// <c>DodoSshApp.ComposeBehind</c> and by nothing else. See the control's own markup for why there is no
|
||||
/// view model behind it.
|
||||
/// </remarks>
|
||||
internal sealed partial class SplashCard : UserControl
|
||||
{
|
||||
public SplashCard() => InitializeComponent();
|
||||
|
||||
/// <summary>Says what is happening, while it still is.</summary>
|
||||
internal void Say(string what) => Line.Text = what;
|
||||
|
||||
/// <summary>
|
||||
/// Says why nothing is going to happen, and stops pretending otherwise.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The line is the whole message now that the progress bar has gone — see the markup for why it went.
|
||||
/// It used to have to stop the bar as well: a composition that threw leaves this window as the only one
|
||||
/// there is, with no main window to hand over to, so anything still claiming to be working would be
|
||||
/// doing it over a process that has stopped. One assignment says it instead, and there is nothing left
|
||||
/// on this card that could contradict it.
|
||||
/// </remarks>
|
||||
internal void CouldNotStart(string why) => Line.Text = why;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:views="using:DodoSSH.Client.App.Views"
|
||||
x:Class="DodoSSH.Client.App.Views.SplashWindow"
|
||||
Title="DodoSSH"
|
||||
Icon="/Assets/dodossh.ico"
|
||||
Width="420"
|
||||
MinHeight="160"
|
||||
SizeToContent="Height"
|
||||
CanResize="False"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Background="{StaticResource Chrome}"
|
||||
WindowDecorations="None">
|
||||
|
||||
<!--
|
||||
── WHY THERE IS A SPLASH AT ALL ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
The main window cannot be shown until it has a view model, and the view model cannot exist until
|
||||
composition has opened the SQLite cache, bound the terminal's loopback socket, asked the platform about
|
||||
a TPM and read the install layout off disk. Until this window existed, all of that happened with nothing
|
||||
whatever on screen: the icon had been double-clicked and the desktop looked unchanged.
|
||||
|
||||
The main window's own IsStarting card already narrates what happens *after* it appears — see
|
||||
MainWindow.axaml — and this is the part before that, which no arrangement of the main window can cover.
|
||||
|
||||
── NOT TOPMOST, AND NO TASKBAR SUPPRESSION ────────────────────────────────────────────────────────────
|
||||
|
||||
Both are the usual splash-screen habits and both are wrong here. Topmost means a start that stalls sits
|
||||
over whatever the user gave up and went back to; hiding the taskbar entry means the one place they would
|
||||
look to find out whether anything is happening says nothing. It is a window, briefly, and behaves like
|
||||
one.
|
||||
|
||||
WindowDecorations="None" rather than the BorderOnly the main window asks for: this cannot be resized, has
|
||||
no titlebar of its own to drag by, and lives for about a second. (The main window still says
|
||||
SystemDecorations, which Avalonia 12 has deprecated in favour of this property; that one is not this
|
||||
change's to move.) What replaces the frame is the card's
|
||||
own one-pixel border. Escape closes it only once there is nothing left to wait for — see the code-behind.
|
||||
|
||||
── SizeToContent, WHICH IS ABOUT THE FAILURE AND NOT ABOUT THE SPLASH ─────────────────────────────────
|
||||
|
||||
A fixed height is right for the one line this normally shows and wrong for the one thing that can replace
|
||||
it: CouldNotStart carries an exception's own message, whose length nothing here decides. Inside a fixed
|
||||
height a long one would wrap and be clipped, at exactly the moment the text is the only thing this window
|
||||
is for. So the height comes from the content, and MinHeight keeps the ordinary one line from looking
|
||||
cramped in a box sized for it alone.
|
||||
-->
|
||||
<views:SplashCard x:Name="Card" />
|
||||
|
||||
</Window>
|
||||
@@ -0,0 +1,56 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>The window that is up while the application composes itself.</summary>
|
||||
/// <remarks>
|
||||
/// A frame around <see cref="SplashCard"/> and nothing else. Its whole lifetime is one call to
|
||||
/// <c>DodoSshApp.ComposeBehind</c>: shown by the desktop lifetime as the main window, replaced by the real
|
||||
/// one, closed. The exception is a composition that fails, which is the only case this window outlives.
|
||||
/// </remarks>
|
||||
internal sealed partial class SplashWindow : Window
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether Escape may close this.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// False while composition is running, and that is not caution for its own sake: this window has no
|
||||
/// close button, so Escape is the only key it could offer — and closing it mid-composition would leave a
|
||||
/// process that has opened the cache and bound a socket with no window and no way to reach either. Once
|
||||
/// there is nothing left to wait for, it is the only way out and has to work.
|
||||
/// </remarks>
|
||||
private bool escapable;
|
||||
|
||||
public SplashWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
KeyDown += (_, e) =>
|
||||
{
|
||||
if (escapable && e.Key == Key.Escape)
|
||||
{
|
||||
Close();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Says what is happening, while it still is.</summary>
|
||||
internal void Say(string what) => Card.Say(what);
|
||||
|
||||
/// <summary>
|
||||
/// Says why nothing is going to happen, and becomes the last window there is.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The way out is named in the message rather than drawn as a button. A failure here is a broken
|
||||
/// installation — a cache that cannot be opened, a port that cannot be bound — so what this window is
|
||||
/// for at that point is carrying one sentence to whoever has to act on it, and a control that has to be
|
||||
/// laid out and measured to say "close me" earns less than the words do.
|
||||
/// </remarks>
|
||||
internal void CouldNotStart(string why)
|
||||
{
|
||||
escapable = true;
|
||||
|
||||
Card.CouldNotStart($"DodoSSH could not start: {why} Press Escape to close.");
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,26 @@ public sealed record ClientPaths(string DataDirectory)
|
||||
/// </remarks>
|
||||
public string DeviceKeyFile => Path.Combine(DataDirectory, "device.key");
|
||||
|
||||
/// <summary>
|
||||
/// The file one desktop instance holds open for as long as it is running.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Named here rather than in the head that uses it, because every other name in this directory is named
|
||||
/// here and a filename invented somewhere else is one nobody looking at the profile can account for. The
|
||||
/// Android head has no use for it: the platform allows one process per package and needs no help.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Being in the profile directory is what scopes it correctly, and that is the whole reason it is a
|
||||
/// file rather than a named mutex.</b> This path is already per user and per channel, so a nightly and a
|
||||
/// release build do not exclude each other and two people signed into one machine each get their own
|
||||
/// instance — neither of which a global name would give without repeating the same two decisions in
|
||||
/// another form. Contents are irrelevant and it stays empty; what carries the meaning is the exclusive
|
||||
/// handle, which the operating system releases when the process ends however it ends.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public string InstanceLockFile => Path.Combine(DataDirectory, "instance.lock");
|
||||
|
||||
/// <summary>Creates the profile directory if it is not there yet.</summary>
|
||||
/// <remarks>
|
||||
/// Separate from resolving the path, because resolving must never have a side effect: it is read
|
||||
|
||||
@@ -348,6 +348,18 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </remarks>
|
||||
private readonly Func<Task<string?>>? readClipboard;
|
||||
|
||||
/// <summary>
|
||||
/// What explains a connection that would not reach its host, handed to every <see cref="VaultViewModel"/>
|
||||
/// this shell builds.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Held rather than passed straight through, because the vault is not built here: it is built on unlock,
|
||||
/// which happens once per session and possibly more than once. Null means the standard probe, which is
|
||||
/// what both heads use — a test passes one to keep a failed connection from putting a real DNS lookup and
|
||||
/// a real socket in the middle of a unit suite.
|
||||
/// </remarks>
|
||||
private readonly ISshReachabilityProbe? reachability;
|
||||
|
||||
/// <inheritdoc cref="MainWindowViewModel(ClientPaths, ClientCacheFactory, TerminalWorkspace, VaultKnownHostStore, IDeviceKeyStore, SignInHandler, TimeProvider, ISftpSessionFactory, Argon2Profile?, ResumeHandler?, Func{string, Task}?, string?, IUpdateChannel?, Action{Action}?)" path="/param[@name='post']" />
|
||||
private readonly Action<Action> post;
|
||||
|
||||
@@ -556,8 +568,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
IUpdateChannel? updates = null,
|
||||
Action<Action>? post = null,
|
||||
Func<string, Task>? openInEditor = null,
|
||||
Func<string, Action, IDisposable>? watchFile = null)
|
||||
Func<string, Action, IDisposable>? watchFile = null,
|
||||
ISshReachabilityProbe? reachability = null)
|
||||
{
|
||||
this.reachability = reachability;
|
||||
this.post = post ?? (action => Dispatcher.UIThread.Post(action));
|
||||
|
||||
this.paths = paths;
|
||||
@@ -2871,7 +2885,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
ReconnectAsync,
|
||||
copyToClipboard,
|
||||
connectionLog,
|
||||
visibility);
|
||||
visibility,
|
||||
reachability);
|
||||
State = ShellState.Unlocked;
|
||||
|
||||
// Offered only where it can actually be honoured: a machine that can keep a key, and a profile that
|
||||
@@ -3746,7 +3761,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
return;
|
||||
}
|
||||
|
||||
var tab = new TerminalTabViewModel(e.Label, e.Address);
|
||||
var tab = new TerminalTabViewModel(e.Label, e.Address) { HostId = e.HostId };
|
||||
attempts[e.AttemptId] = tab;
|
||||
|
||||
AdoptTab(tab);
|
||||
@@ -3810,6 +3825,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
// dropping it would leave a shell running with nothing in the window naming it.
|
||||
var adopted = new TerminalTabViewModel(e.SessionId, e.Label, e.Address)
|
||||
{
|
||||
HostId = e.HostId,
|
||||
StartedAt = clock.GetUtcNow(),
|
||||
Cipher = NullIfEmpty(e.Cipher),
|
||||
HostKeyAlgorithm = NullIfEmpty(e.HostKeyAlgorithm),
|
||||
@@ -4021,13 +4037,22 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The host this tab was opened against, or null when the keychain has nothing under that name.
|
||||
/// The host this tab was opened against, or null when the keychain no longer holds it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Matched on the label, which is the same handle <see cref="RefreshConnectedHosts"/> matches on and for
|
||||
/// the same reason: a tab outlives the vault that opened it, so it cannot hold an entity id that would
|
||||
/// still mean anything after a lock.
|
||||
/// ◆ <b>Matched on the keychain item's id, never on the name.</b> Nothing stops two hosts being called
|
||||
/// the same thing — a team's vault beside a personal one makes it ordinary, and "db" is what people call
|
||||
/// their database server in both — and this is the lookup <see cref="ReconnectTabAsync"/> dials the
|
||||
/// answer to. A name match would take a dropped session on one machine and quietly reopen a shell on
|
||||
/// another, with the tab, the status bar and the address all still naming the first. The id is carried
|
||||
/// from the attempt that opened the tab; see <see cref="TerminalTabViewModel.HostId"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It outlives a lock, which is what the name match was here to survive.</b> The id is the item's own
|
||||
/// sync identity rather than a reference to a row object, so a vault that is locked and opened again
|
||||
/// rebuilds its list around the same ids and a tab from before the lock finds its host in it. A host
|
||||
/// renamed since the tab opened is found too.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Null is the ordinary answer for two real cases, and both of them are why the reconnect below has to be
|
||||
@@ -4036,10 +4061,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private HostRowViewModel? HostFor(TerminalTabViewModel? tab) =>
|
||||
tab is null || Vault is not { } vault
|
||||
tab?.HostId is not { } hostId || Vault is not { } vault
|
||||
? null
|
||||
: vault.Hosts.FirstOrDefault(
|
||||
row => string.Equals(row.Label, tab.Label, StringComparison.Ordinal));
|
||||
: vault.Hosts.FirstOrDefault(row => row.EntityId == hostId);
|
||||
|
||||
/// <summary>
|
||||
/// Whether the selected tab has stopped and there is something to dial again.
|
||||
@@ -4367,10 +4391,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Matched on the label, which is what a tab was named after, because that is the only handle the two
|
||||
/// lists share — a tab outlives the vault that opened it, so it cannot hold an entity id that would
|
||||
/// still mean anything after a lock. Two hosts sharing a name would light both dots, which is a smaller
|
||||
/// wrong than a dot that goes dark when the vault is reopened.
|
||||
/// Matched on the keychain item's id, the same handle <see cref="HostFor"/> matches on and for the
|
||||
/// reason given there: two hosts may share a name, and this list is read as "which machines am I on".
|
||||
/// Lighting both dots for a name two machines answer to would say the user has a shell open on a host
|
||||
/// they have never connected to — and it is the same match that decides whose pinned paths the sidebar
|
||||
/// offers, which would then be another host's folders.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A tab with no item behind it — a machine reached by typing an address — lights nothing, which is
|
||||
/// correct: there is no row here that it is a session on.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="ActiveTabPinnedPaths"/> is rebuilt here, on the same match, rather than from a subscription
|
||||
@@ -4393,14 +4422,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
foreach (var host in vault.Hosts)
|
||||
{
|
||||
host.IsConnected = Tabs.Any(
|
||||
tab => tab.IsLive && string.Equals(tab.Label, host.Label, StringComparison.Ordinal));
|
||||
host.IsConnected = Tabs.Any(tab => tab.IsLive && tab.HostId == host.EntityId);
|
||||
|
||||
// The active tab's host, and only when it is actually connected: the sidebar is for a session
|
||||
// that is open, not for whichever machine's tab happens to be selected while it is still dialling.
|
||||
if (host.IsConnected
|
||||
&& SelectedTab is { } selected
|
||||
&& string.Equals(host.Label, selected.Label, StringComparison.Ordinal))
|
||||
if (host.IsConnected && SelectedTab is { } selected && selected.HostId == host.EntityId)
|
||||
{
|
||||
connectedActiveHost = host;
|
||||
}
|
||||
@@ -4534,15 +4560,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// is drawn and not what it does. It shares <see cref="OnVaultFilesRequested"/>'s plumbing — the same
|
||||
/// <see cref="ShowFiles"/> refusal, the same re-found row, the same password-sheet branch for a host
|
||||
/// that cannot be dialled unattended — through <see cref="GoToHostFilesAsync"/>, and the host is found by
|
||||
/// the same label match <see cref="RefreshConnectedHosts"/> used to decide the row is there to click.
|
||||
/// the same <see cref="HostFor"/> match <see cref="RefreshConnectedHosts"/> used to decide the row is
|
||||
/// there to click — which is the point: the pins on screen and the host they are opened on come from one
|
||||
/// answer to "which machine is this tab", and two hosts of the same name cannot pull them apart.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task OpenPinnedPathAsync(string path)
|
||||
{
|
||||
if (Vault is not { } vault
|
||||
|| SelectedTab is not { } tab
|
||||
|| vault.Hosts.FirstOrDefault(
|
||||
host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
|
||||
if (HostFor(SelectedTab) is not { } host)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -4623,10 +4648,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
[RelayCommand]
|
||||
private void PinFolderFromSidebar()
|
||||
{
|
||||
if (Vault is not { } vault
|
||||
|| SelectedTab is not { } tab
|
||||
|| vault.Hosts.FirstOrDefault(
|
||||
host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
|
||||
if (Vault is not { } vault || HostFor(SelectedTab) is not { } host)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -4659,10 +4681,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
[RelayCommand]
|
||||
private async Task SelectFilesHostAsync(TerminalTabViewModel tab)
|
||||
{
|
||||
if (Vault is not { } vault
|
||||
|| tab is null
|
||||
|| vault.Hosts.FirstOrDefault(
|
||||
host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
|
||||
if (HostFor(tab) is not { } host)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -229,6 +229,31 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
||||
|
||||
internal string Label { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The keychain item this tab was opened from, or null for a machine reached by typing an address.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>What the shell matches this tab back to a host row with, because the name will not do it.</b>
|
||||
/// Nothing stops two hosts being called the same thing, and a team's vault beside a personal one makes it
|
||||
/// ordinary — so a lookup on <see cref="Label"/> can answer with a different machine that happens to
|
||||
/// share the name. The reconnect under a stopped tab <em>dials</em> whatever that lookup finds, which is
|
||||
/// the one place being approximately right is dialling a stranger. See
|
||||
/// <c>MainWindowViewModel.HostFor</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It survives a lock, which the row objects do not.</b> This is the item's sync identity rather than
|
||||
/// an index into the list on screen — see <c>VaultItem<TSecret>.EntityId</c> — so a vault that is
|
||||
/// locked and opened again rebuilds every row around the same ids, and a tab that outlived the vault
|
||||
/// finds its host again. A rename finds it too, which a name match by definition cannot.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Null is not missing data: it is the whole of what makes a typed-in address different, and it is why
|
||||
/// such a tab is honestly refused a reconnect rather than offered one that would dial a namesake.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal Guid? HostId { get; init; }
|
||||
|
||||
/// <summary>The account and endpoint, for the pane header and the status bar.</summary>
|
||||
internal string Address { get; }
|
||||
|
||||
|
||||
@@ -947,6 +947,7 @@ internal static class ItemBadge
|
||||
/// <param name="identityLabel">
|
||||
/// The display name of the key or credential that authenticated, or null when a typed password did.
|
||||
/// </param>
|
||||
/// <param name="hostId">The keychain item this was dialled from, or null for a typed-in address.</param>
|
||||
internal sealed class TerminalSessionEventArgs(
|
||||
Guid attemptId,
|
||||
uint sessionId,
|
||||
@@ -954,7 +955,8 @@ internal sealed class TerminalSessionEventArgs(
|
||||
string address,
|
||||
string cipher,
|
||||
string hostKeyAlgorithm,
|
||||
string? identityLabel) : EventArgs
|
||||
string? identityLabel,
|
||||
Guid? hostId) : EventArgs
|
||||
{
|
||||
/// <summary>Which attempt this session came out of.</summary>
|
||||
/// <inheritdoc cref="ConnectionAttemptEventArgs.AttemptId" path="/remarks" />
|
||||
@@ -971,19 +973,27 @@ internal sealed class TerminalSessionEventArgs(
|
||||
internal string HostKeyAlgorithm { get; } = hostKeyAlgorithm;
|
||||
|
||||
internal string? IdentityLabel { get; } = identityLabel;
|
||||
|
||||
/// <inheritdoc cref="ConnectionAttemptEventArgs.HostId" />
|
||||
internal Guid? HostId { get; } = hostId;
|
||||
}
|
||||
|
||||
/// <summary>A connection that has been asked for, and has not answered yet.</summary>
|
||||
/// <param name="attemptId">Identifies this attempt for the whole of its life.</param>
|
||||
/// <param name="label">The host's name, as the vault has it.</param>
|
||||
/// <param name="address">Who this will be logged in as, and where.</param>
|
||||
/// <param name="hostId">The keychain item this is being dialled from, or null for a typed-in address.</param>
|
||||
/// <remarks>
|
||||
/// The vault says a connection has started before it says whether it worked, so that the shell can put a
|
||||
/// tab in the strip at the moment the user asks for one rather than however many seconds later a handshake
|
||||
/// takes. Everything a tab needs to name itself is here, because the name is a decrypted item and the shell
|
||||
/// has no vault to read it from.
|
||||
/// </remarks>
|
||||
internal sealed class ConnectionAttemptEventArgs(Guid attemptId, string label, string address) : EventArgs
|
||||
internal sealed class ConnectionAttemptEventArgs(
|
||||
Guid attemptId,
|
||||
string label,
|
||||
string address,
|
||||
Guid? hostId) : EventArgs
|
||||
{
|
||||
/// <summary>Identifies this attempt for the whole of its life.</summary>
|
||||
/// <remarks>
|
||||
@@ -995,6 +1005,19 @@ internal sealed class ConnectionAttemptEventArgs(Guid attemptId, string label, s
|
||||
internal string Label { get; } = label;
|
||||
|
||||
internal string Address { get; } = address;
|
||||
|
||||
/// <summary>
|
||||
/// The keychain item this connection was made from, or null for a machine that is not in the keychain.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ◆ <b>The name is not an identifier and this is.</b> Two hosts may be called the same thing — nothing
|
||||
/// stops it, and a team vault next to a personal one makes it ordinary — so a tab that remembered only
|
||||
/// what it was called could be matched back to the wrong machine. That is not a cosmetic mix-up: the
|
||||
/// reconnect below the tab dials whichever row the match found, so the one thing that must not be
|
||||
/// guessed is which machine a reconnect goes to. Carried from here because a tab has no vault to ask —
|
||||
/// see <c>TerminalTabViewModel.HostId</c>.
|
||||
/// </remarks>
|
||||
internal Guid? HostId { get; } = hostId;
|
||||
}
|
||||
|
||||
/// <summary>A connection that has got as far as a named step.</summary>
|
||||
@@ -1445,8 +1468,18 @@ internal sealed partial class VaultViewModel(
|
||||
ServerReconnectHandler? reconnect = null,
|
||||
Func<string, Task>? copyToClipboard = null,
|
||||
ConnectionRecorder? connectionLog = null,
|
||||
VaultVisibility? visibility = null) : ObservableObject, IAsyncDisposable
|
||||
VaultVisibility? visibility = null,
|
||||
ISshReachabilityProbe? reachability = null) : ObservableObject, IAsyncDisposable
|
||||
{
|
||||
/// <summary>What explains a failed connection when no caller supplied something else.</summary>
|
||||
/// <remarks>
|
||||
/// Static and shared, because <see cref="SshReachabilityProbe"/> holds nothing but a budget and every
|
||||
/// call to it is a fresh socket. Last in the parameter list and optional for the reason
|
||||
/// <c>visibility</c> is: every existing caller — which is both heads and every test — goes on meaning
|
||||
/// exactly what it did, and the one that wants to watch what was probed passes a substitute.
|
||||
/// </remarks>
|
||||
private static readonly ISshReachabilityProbe DefaultReachability = new SshReachabilityProbe();
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A minute. The pull is a delta keyed on a cursor, so an idle pass is one small request and costs the
|
||||
@@ -11233,7 +11266,52 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
private sealed class PhaseReporter(Action<SshConnectionPhase> report) : IProgress<SshConnectionPhase>
|
||||
{
|
||||
public void Report(SshConnectionPhase value) => report(value);
|
||||
private int furthest = -1;
|
||||
|
||||
/// <summary>
|
||||
/// The furthest phase reported, or null if the handshake never reported one at all.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Kept because a failure cannot be read without it: <see cref="ExplainAsync"/> probes the endpoint
|
||||
/// only for a connection that stopped while reaching, and this is the record of how far it got.
|
||||
/// Nothing else asks — the card is driven by the events, not by this.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>An <see cref="int"/> behind a <see cref="Volatile"/> pair, because the write and the read are
|
||||
/// on different threads.</b> Phases are reported from whichever thread the handshake is on — SSH.NET
|
||||
/// raises host key verification on its own message thread, which is where
|
||||
/// <see cref="SshConnectionPhase.CheckingHostKey"/> and
|
||||
/// <see cref="SshConnectionPhase.Authenticating"/> come from — and this is read on the UI thread
|
||||
/// after the attempt has failed. The <c>await</c> in between orders them in practice; nothing in the
|
||||
/// language says so, and a cached read here would probe a host that had already offered a key.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Monotonic, so a phase cannot go backwards.</b> The enum only ever advances today, and the
|
||||
/// maximum rather than the latest is what keeps that an observation rather than something this type
|
||||
/// relies on: a rekey mid-handshake raising <c>HostKeyReceived</c> a second time must not be able to
|
||||
/// walk the record back to <see cref="SshConnectionPhase.CheckingHostKey"/> from
|
||||
/// <see cref="SshConnectionPhase.OpeningShell"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public SshConnectionPhase? Furthest =>
|
||||
Volatile.Read(ref furthest) is var seen && seen < 0 ? null : (SshConnectionPhase)seen;
|
||||
|
||||
public void Report(SshConnectionPhase value)
|
||||
{
|
||||
var reported = (int)value;
|
||||
|
||||
// Read-compare-write rather than an interlocked maximum, which the BCL has no primitive for. The
|
||||
// race it loses is two phases reported at the same instant from two threads, which this
|
||||
// handshake does not do — it is one connection making one call at a time — and the cost of
|
||||
// losing it would be one phase forgotten rather than a wrong answer.
|
||||
if (reported > Volatile.Read(ref furthest))
|
||||
{
|
||||
Volatile.Write(ref furthest, reported);
|
||||
}
|
||||
|
||||
report(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What a manual target reads as, once it has been taken apart.</summary>
|
||||
@@ -11271,7 +11349,8 @@ internal sealed partial class VaultViewModel(
|
||||
var attempt = new ConnectionAttemptEventArgs(
|
||||
Guid.CreateVersion7(),
|
||||
target.Label,
|
||||
Dialled(target, authentication));
|
||||
Dialled(target, authentication),
|
||||
target.HostId);
|
||||
|
||||
ConnectionStarting?.Invoke(this, attempt);
|
||||
Status = $"Connecting to {target.Label}…";
|
||||
@@ -11506,9 +11585,13 @@ internal sealed partial class VaultViewModel(
|
||||
HostAuthentication authentication,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Built here rather than inside the call, because the catch blocks below read it: how far the
|
||||
// handshake got is what says whether a failure is worth probing the endpoint over. See ExplainAsync.
|
||||
var reporter = ReporterFor(attempt);
|
||||
|
||||
try
|
||||
{
|
||||
await ConnectAndAnnounceAsync(attempt, target, authentication, cancellationToken)
|
||||
await ConnectAndAnnounceAsync(attempt, target, authentication, reporter, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
@@ -11534,10 +11617,87 @@ internal sealed partial class VaultViewModel(
|
||||
catch (Exception exception)
|
||||
{
|
||||
RecordFailure(target, authentication, ConnectionOutcome.Failed);
|
||||
Abandon(attempt, exception.Message);
|
||||
|
||||
Abandon(
|
||||
attempt,
|
||||
await ExplainAsync(exception, target, reporter, cancellationToken).ConfigureAwait(true));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What to tell the user about a connection that failed, having gone and looked when the message alone
|
||||
/// does not say.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>Because "Connection has timed out." is what SSH.NET says for two unrelated faults.</b> A machine
|
||||
/// that never answered and a machine that answered TCP and then never spoke SSH arrive here as the same
|
||||
/// five words, and they have nothing in common to do about them — the first is a network, a firewall or a
|
||||
/// wrong address, the second is an <c>sshd</c> that is wedged or a port that is not SSH. That was the
|
||||
/// whole of what this screen could say about the failure users hit most, so it now goes and finds out;
|
||||
/// see <see cref="ISshReachabilityProbe"/> for what it does and deliberately does not do.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Only for a failure that happened while reaching, and the phase is what settles that.</b> The window
|
||||
/// is exactly <see cref="SshConnectionPhase.Reaching"/>, and it is closed at both ends. Past it the
|
||||
/// machine has demonstrably been reached — it offered a host key — so a refused password or a key the
|
||||
/// remote would not take is not a reachability question, and probing the port would answer one nobody
|
||||
/// asked while telling somebody their network is fine when they already know.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>And before it, nothing has been dialled yet, which is the half that would lie.</b> No phase at
|
||||
/// all means the attempt never reached <c>ConnectAsync</c>: a renderer that did not attach, a credential
|
||||
/// that would not decrypt, a private key that would not parse — <c>BuildConnectionInfo</c> throws on that
|
||||
/// last one before the factory reports anything. Probing there finds a healthy port and appends "the SSH
|
||||
/// handshake is what did not finish" to a message about an unreadable key, which is a confident wrong
|
||||
/// answer where there was an accurate one. The phases are reported at the instant each begins and are not
|
||||
/// guesses; see the enum.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The original message is kept and the finding is added to it.</b> What SSH.NET said is the fact, and
|
||||
/// this is a reading of a second measurement — dropping the first would leave nothing to check the second
|
||||
/// against, and would lose the one case where the exception is already specific.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A probe that itself fails changes nothing.</b> The user is being told about a connection that did
|
||||
/// not happen; a diagnostic that cannot run is not a second failure worth reporting, and must not be able
|
||||
/// to replace the first with its own exception. Cancellation is in that: a user who closed the tab while
|
||||
/// this ran is not waiting for the answer.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<string> ExplainAsync(
|
||||
Exception exception,
|
||||
ConnectionTarget target,
|
||||
PhaseReporter reporter,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (reporter.Furthest != SshConnectionPhase.Reaching)
|
||||
{
|
||||
return exception.Message;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var report = await Reachability
|
||||
.ProbeAsync(target.Hostname, target.Port, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
// A probe with nothing to say leaves the message exactly as it was, rather than appending a
|
||||
// space to it. The real one always has a sentence; this is what keeps a substitute that does not
|
||||
// from changing the text of every failure that goes past it.
|
||||
return report.Summary is { Length: > 0 } finding
|
||||
? $"{exception.Message} {finding}"
|
||||
: exception.Message;
|
||||
}
|
||||
catch (Exception probe) when (probe is not OutOfMemoryException)
|
||||
{
|
||||
return exception.Message;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="DefaultReachability" />
|
||||
private ISshReachabilityProbe Reachability => reachability ?? DefaultReachability;
|
||||
|
||||
/// <summary>What a renderer that never attached is reported as.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -11603,6 +11763,7 @@ internal sealed partial class VaultViewModel(
|
||||
ConnectionAttemptEventArgs attempt,
|
||||
ConnectionTarget target,
|
||||
HostAuthentication authentication,
|
||||
PhaseReporter reporter,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Not reported before the await: the tab is constructed with this step already running — see
|
||||
@@ -11616,7 +11777,7 @@ internal sealed partial class VaultViewModel(
|
||||
authentication.Credential);
|
||||
|
||||
var sessionId = await workspace
|
||||
.OpenSessionAsync(request, TerminalSize.Default, ReporterFor(attempt), cancellationToken)
|
||||
.OpenSessionAsync(request, TerminalSize.Default, reporter, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
// The workspace has already opened a ticket for this session, with the address and the moment it
|
||||
@@ -11647,7 +11808,8 @@ internal sealed partial class VaultViewModel(
|
||||
Dialled(target, authentication),
|
||||
facts?.Cipher ?? string.Empty,
|
||||
facts?.HostKeyAlgorithm ?? string.Empty,
|
||||
authentication.IdentityLabel));
|
||||
authentication.IdentityLabel,
|
||||
target.HostId));
|
||||
|
||||
// Last, and after the tab exists: keeping the password is a favour, and the session the user asked
|
||||
// for must not wait on a vault write to appear.
|
||||
|
||||
@@ -34,6 +34,7 @@ const SERVER_SESSION_REMOVED = 5;
|
||||
const SERVER_PASTE = 6;
|
||||
const SERVER_FONT_SIZE = 7;
|
||||
const SERVER_CLIPBOARD = 8;
|
||||
const SERVER_OUTPUT_DROPPED = 9;
|
||||
|
||||
const CLIENT_INPUT = 1;
|
||||
const CLIENT_ACKNOWLEDGE = 2;
|
||||
@@ -534,6 +535,24 @@ function handleFrame(buffer) {
|
||||
break;
|
||||
}
|
||||
|
||||
case SERVER_OUTPUT_DROPPED: {
|
||||
const session = sessions.get(sessionId) ?? createSession(sessionId);
|
||||
|
||||
/*
|
||||
The host could not keep the whole of a flood, and this is the line saying where the gap is. It
|
||||
goes in exactly here, between the bytes either side of it — the socket keeps order within a
|
||||
session, so nothing has to be done to place it.
|
||||
|
||||
Written without an acknowledgement, deliberately. Credit describes what the remote sent and what
|
||||
this page has drawn of it; these bytes came from the host itself, and acknowledging them would
|
||||
hand back credit for output that was never reserved against the window in the first place. The
|
||||
payload arrives already styled and already terminated by a reset — see the host's own remark for
|
||||
why the reset comes first as well.
|
||||
*/
|
||||
session.term.write(payload);
|
||||
break;
|
||||
}
|
||||
|
||||
case SERVER_SESSION_ACTIVATED: {
|
||||
const session = sessions.get(sessionId);
|
||||
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace DodoSSH.Client.Ssh;
|
||||
|
||||
/// <summary>
|
||||
/// What one look at an endpoint found, once a connection to it had already failed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>Four, because they are the four a user would do something different about.</b> A name that does not
|
||||
/// resolve is a typo or a VPN that is down; a port that refuses is a machine that is up with no <c>sshd</c>
|
||||
/// on it; a port that says nothing at all is a firewall or a machine that is off; and a port that opens is
|
||||
/// the one that matters most, because it means the network is fine and the SSH handshake is what failed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Nothing here is inferred from the exception that started it.</b> Each value is what a socket actually
|
||||
/// did, measured at the moment it was asked, which is what makes it safe for a screen to state as fact —
|
||||
/// the same rule <see cref="SshConnectionPhase"/> is written to.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public enum SshReach
|
||||
{
|
||||
/// <summary>The name does not resolve on this machine, so there was no address to dial.</summary>
|
||||
Unresolved = 0,
|
||||
|
||||
/// <summary>It resolved, and the port sent nothing back before the probe gave up.</summary>
|
||||
NoAnswer = 1,
|
||||
|
||||
/// <summary>It resolved, and the host said at once that nothing is listening there.</summary>
|
||||
Refused = 2,
|
||||
|
||||
/// <summary>It resolved, and something accepted a plain TCP connection on that port.</summary>
|
||||
Open = 3,
|
||||
}
|
||||
|
||||
/// <summary>What a probe of one endpoint found, and the sentence that says so.</summary>
|
||||
/// <param name="Reach">Which of the four it was.</param>
|
||||
/// <param name="Host">The name or address that was probed, as the connection named it.</param>
|
||||
/// <param name="Port">The port that was probed.</param>
|
||||
/// <param name="Address">
|
||||
/// The address the name resolved to and the probe actually dialled, or null when it did not resolve.
|
||||
/// <para>
|
||||
/// One rather than all of them. A name with several addresses is dialled at whichever the stack picks
|
||||
/// first, and naming the one that was tried is a fact; listing the rest would suggest they were.
|
||||
/// </para>
|
||||
/// </param>
|
||||
/// <param name="Elapsed">How long the probe took, which is the evidence behind <see cref="SshReach.NoAnswer"/>.</param>
|
||||
/// <param name="Summary">The finding in plain words, ready to put in front of somebody.</param>
|
||||
public sealed record SshReachabilityReport(
|
||||
SshReach Reach,
|
||||
string Host,
|
||||
int Port,
|
||||
string? Address,
|
||||
TimeSpan Elapsed,
|
||||
string Summary);
|
||||
|
||||
/// <summary>Looks at an endpoint that would not connect, and reports what is actually wrong with it.</summary>
|
||||
/// <remarks>
|
||||
/// Separate from <see cref="ISshConnectionFactory"/> rather than a method on it, because it is not a way of
|
||||
/// connecting: it runs after one has failed, it authenticates nothing, and it must stay usable from a caller
|
||||
/// that has no credential to hand — which the failure path is, by the time it needs this.
|
||||
/// </remarks>
|
||||
public interface ISshReachabilityProbe
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the name and opens a bare TCP connection to the port, to find out which part is broken.
|
||||
/// </summary>
|
||||
/// <param name="host">The hostname or address the failed connection named.</param>
|
||||
/// <param name="port">The port it named.</param>
|
||||
/// <param name="cancellationToken">Abandons the probe.</param>
|
||||
/// <remarks>
|
||||
/// Never throws for a host that is simply broken — being unreachable is the answer, not an error. A
|
||||
/// caller is already in a failure path and has nothing useful to do with a second exception.
|
||||
/// </remarks>
|
||||
ValueTask<SshReachabilityReport> ProbeAsync(string host, int port, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>Why this exists at all: two unrelated failures arrive as the same sentence.</b> SSH.NET reports both
|
||||
/// "the machine never answered" and "the machine answered TCP and then never spoke SSH" as
|
||||
/// <c>SshOperationTimeoutException</c> with the message "Connection has timed out." Those are not the same
|
||||
/// problem and do not have the same fix — one is a network or a firewall, the other is an <c>sshd</c> that
|
||||
/// is wedged or a port that is not SSH at all — and nothing in the exception tells them apart. A socket does,
|
||||
/// in about as long as it takes to open one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A plain TCP connect and a name lookup, and deliberately nothing else.</b> No ping, because ICMP is
|
||||
/// filtered on most of the networks this product is pointed at and an unanswered ping would be evidence of
|
||||
/// nothing. No scan of neighbouring ports: the question is what happened to the connection the user asked
|
||||
/// for, and probing ports nobody asked about is the kind of thing that gets a client flagged by an intrusion
|
||||
/// detector on the very network somebody is trying to work on. No SSH banner read either — that is a second
|
||||
/// handshake against a server that has already failed one, and the open socket has answered the question.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The whole probe is bounded, and the bound is short.</b> By the time this runs the user has already
|
||||
/// waited out a connect timeout, so the budget here is a few seconds spent buying an answer rather than
|
||||
/// another wait to sit through. Exceeding it is itself a finding — nothing came back — so the deadline
|
||||
/// produces a result rather than an error.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SshReachabilityProbe : ISshReachabilityProbe
|
||||
{
|
||||
/// <summary>
|
||||
/// How long the name lookup and the connection attempt get between them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A budget rather than two timeouts, so a slow resolver cannot spend the socket's share and leave the
|
||||
/// interesting half of the probe unrun. It is well under the fifteen this client allows a real connection
|
||||
/// — see <c>SshNetConnectionFactory</c> — because a machine that answers at all answers a bare TCP
|
||||
/// connect in a fraction of what an SSH handshake takes, and everything past that point is waiting to
|
||||
/// confirm silence somebody has already waited out once.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>Six rather than two, and the floor under it is not a guess.</b> A refusal is not instant on
|
||||
/// Windows: a closed port on loopback took ~2s to come back as <c>WSAECONNREFUSED</c> when this was
|
||||
/// measured, because the stack retries the SYN before reporting. Anything at or under that turns
|
||||
/// <see cref="SshReach.Refused"/> — a machine that is up — into <see cref="SshReach.NoAnswer"/>, which
|
||||
/// this client would then explain as a firewall. The budget must clear the platform's SYN-retry time
|
||||
/// with room to spare, and a couple of seconds of headroom is what the extra four buy. Shortening this
|
||||
/// without re-measuring that floor reintroduces a wrong answer rather than a slow one.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static readonly TimeSpan Budget = TimeSpan.FromSeconds(6);
|
||||
|
||||
private readonly TimeSpan budget;
|
||||
|
||||
/// <summary>Uses the standard <see cref="Budget"/>.</summary>
|
||||
public SshReachabilityProbe()
|
||||
: this(Budget)
|
||||
{
|
||||
}
|
||||
|
||||
/// <param name="budget">
|
||||
/// How long the whole probe gets. A test uses this to keep a deliberately silent endpoint from costing
|
||||
/// the suite six seconds; nothing in the application passes one.
|
||||
/// </param>
|
||||
public SshReachabilityProbe(TimeSpan budget) => this.budget = budget;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<SshReachabilityReport> ProbeAsync(
|
||||
string host,
|
||||
int port,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(host);
|
||||
|
||||
var clock = Stopwatch.StartNew();
|
||||
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
deadline.CancelAfter(budget);
|
||||
|
||||
var address = await ResolveAsync(host, deadline.Token).ConfigureAwait(false);
|
||||
|
||||
if (address is null)
|
||||
{
|
||||
return Report(SshReach.Unresolved, host, port, address: null, clock.Elapsed);
|
||||
}
|
||||
|
||||
var reach = await ConnectAsync(address, port, deadline.Token).ConfigureAwait(false);
|
||||
|
||||
return Report(reach, host, port, address.ToString(), clock.Elapsed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The address the connection would have been made to, or null if the name does not resolve.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An address typed in rather than a name costs no lookup: <see cref="IPAddress.TryParse(string, out IPAddress)"/>
|
||||
/// settles it here, where <see cref="Dns"/> would go and ask the resolver about a string it is going to
|
||||
/// hand straight back. That matters for the report as much as for the speed — a literal that "resolved"
|
||||
/// would be a fact this probe invented about a machine it never asked anybody about.
|
||||
/// </remarks>
|
||||
private static async ValueTask<IPAddress?> ResolveAsync(string host, CancellationToken cancellationToken)
|
||||
{
|
||||
if (IPAddress.TryParse(host, out var literal))
|
||||
{
|
||||
return literal;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var addresses = await Dns.GetHostAddressesAsync(host, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// First rather than a preference between v4 and v6: this is reporting on the connection that
|
||||
// failed, and the stack picked from this same list in this same order when it made it.
|
||||
return addresses.Length == 0 ? null : addresses[0];
|
||||
}
|
||||
catch (Exception exception) when (exception is SocketException or OperationCanceledException)
|
||||
{
|
||||
// A resolver that refuses and a resolver that never answers are the same finding here: there is
|
||||
// no address, so there was nothing to dial. Which of the two it was is a DNS question, and this
|
||||
// probe would be guessing at the answer.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The socket is opened and dropped without a byte being sent. A TCP handshake is the whole question —
|
||||
/// whether anything is listening — and writing to a server that is about to be told nothing is wanted
|
||||
/// would leave a connection attempt in its log that the user did not make.
|
||||
/// </remarks>
|
||||
private static async ValueTask<SshReach> ConnectAsync(
|
||||
IPAddress address,
|
||||
int port,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
|
||||
|
||||
try
|
||||
{
|
||||
await socket.ConnectAsync(new IPEndPoint(address, port), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return SshReach.Open;
|
||||
}
|
||||
catch (SocketException exception) when (exception.SocketErrorCode is SocketError.ConnectionRefused)
|
||||
{
|
||||
return SshReach.Refused;
|
||||
}
|
||||
catch (Exception exception) when (exception is SocketException or OperationCanceledException)
|
||||
{
|
||||
// Everything else the stack can say here — unreachable, timed out, the deadline above — comes to
|
||||
// the same account of it: the port never answered. Naming the OS error instead would be trading a
|
||||
// sentence somebody can act on for one they would have to look up.
|
||||
return SshReach.NoAnswer;
|
||||
}
|
||||
}
|
||||
|
||||
private static SshReachabilityReport Report(
|
||||
SshReach reach,
|
||||
string host,
|
||||
int port,
|
||||
string? address,
|
||||
TimeSpan elapsed)
|
||||
=> new(reach, host, port, address, elapsed, Describe(reach, host, port, address, elapsed));
|
||||
|
||||
/// <summary>
|
||||
/// The finding as a sentence, saying what was measured and then what it means.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Measurement first in every case, on the rule the rest of this product's failure text follows: the
|
||||
/// fact is what the user can check and act on, and the interpretation after it is this client's reading
|
||||
/// of that fact. Each reading is hedged exactly as far as it deserves — a refusal has one common cause
|
||||
/// and names it, silence has three and lists them, and an open port names the class of problem rather
|
||||
/// than pretending to know which member of it this is.
|
||||
/// </remarks>
|
||||
private static string Describe(SshReach reach, string host, int port, string? address, TimeSpan elapsed)
|
||||
{
|
||||
var seconds = elapsed.TotalSeconds.ToString("0.#", CultureInfo.CurrentCulture);
|
||||
var where = address is null || string.Equals(address, host, StringComparison.OrdinalIgnoreCase)
|
||||
? host
|
||||
: $"{host} ({address})";
|
||||
|
||||
return reach switch
|
||||
{
|
||||
SshReach.Unresolved =>
|
||||
$"The name {host} does not resolve on this machine, so nothing was ever dialled. Check the "
|
||||
+ "spelling, or whether the VPN or DNS server this name depends on is up.",
|
||||
|
||||
SshReach.Refused =>
|
||||
$"{where} is reachable and refused port {port} immediately: the machine is up and nothing is "
|
||||
+ "listening there. Either sshd is not running, or this is the wrong port.",
|
||||
|
||||
SshReach.NoAnswer =>
|
||||
$"{where} sent nothing back on port {port} within {seconds}s. A machine that was up and had "
|
||||
+ "nothing listening would have refused at once, so this is a firewall dropping the "
|
||||
+ "packets, a machine that is off, or the wrong address.",
|
||||
|
||||
_ =>
|
||||
$"Port {port} on {where} accepts connections, so the network and the machine are fine and "
|
||||
+ "something is listening. The SSH handshake is what did not finish: an sshd that is "
|
||||
+ "overloaded or wedged, a middlebox holding the connection open, or a service on this "
|
||||
+ "port that is not SSH.",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,33 @@
|
||||
namespace DodoSSH.Client.Terminal;
|
||||
|
||||
/// <summary>
|
||||
/// Credit-based flow control over one terminal session's output.
|
||||
/// Credit-based flow control over what has been sent to the renderer and not yet drawn.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is what makes <c>yes</c> survivable. A terminal emulator renders at 60 Hz at best, while a
|
||||
/// remote process can produce output as fast as the network allows. Without a limit the difference
|
||||
/// accumulates somewhere — an unbounded queue in the client, or an ever-growing scrollback — and the
|
||||
/// application's memory grows until it dies.
|
||||
/// A terminal emulator renders at 60 Hz at best, while a remote process can produce output as fast as
|
||||
/// the network allows. The difference has to accumulate somewhere, and the one place it must never
|
||||
/// accumulate is inside the WebView: xterm queues whatever it is written and nothing on this side can
|
||||
/// see how deep that queue is, so a page told to draw faster than it can grows until the tab dies.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The mechanism: the renderer is granted a window of bytes it is allowed to be behind by. Each byte
|
||||
/// sent to it consumes credit; each byte it reports having actually rendered returns credit. When
|
||||
/// credit reaches zero the pump <b>stops reading the SSH channel</b>. That closes SSH's own receive
|
||||
/// window, which makes the remote <c>sshd</c> block on write, which propagates the backpressure all
|
||||
/// the way to the process producing the output. Nothing buffers without bound because nothing is read
|
||||
/// that cannot be delivered.
|
||||
/// credit reaches zero the pump sends nothing more until some comes back.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The window has to be large enough that a normal burst never stalls and small enough to bound
|
||||
/// memory. 256 KiB is roughly a screenful of dense output many times over, and it caps a session's
|
||||
/// in-flight cost at a quarter of a megabyte.
|
||||
/// ◆ <b>What this does not do is bound what the remote has sent</b>, and it used to be written as
|
||||
/// though it did — the claim was that a pump which stops reading closes SSH's own receive window and
|
||||
/// blocks the remote <c>sshd</c>. It does not, because SSH.NET re-opens that window as bytes arrive
|
||||
/// rather than as they are consumed. Holding output back therefore never reached the remote at all;
|
||||
/// it only chose which buffer the flood landed in, and the one it landed in had no ceiling. The bound
|
||||
/// that was missing now lives in <see cref="OutputBacklog"/>, which is also where the evidence for
|
||||
/// this paragraph is written down.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The window has to be large enough that a normal burst never stalls and small enough to bound what
|
||||
/// the page is holding. 256 KiB is roughly a screenful of dense output many times over, and it caps a
|
||||
/// session's in-flight cost at a quarter of a megabyte.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class CreditWindow
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
namespace DodoSSH.Client.Terminal;
|
||||
|
||||
/// <summary>
|
||||
/// One session's bounded queue of output waiting for the renderer, oldest first.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>This is where a session's memory is actually bounded, and it exists because the obvious place
|
||||
/// cannot do it.</b> The design used to be that <see cref="CreditWindow"/> bounded everything: the pump
|
||||
/// stopped reading the SSH channel when the renderer fell behind, SSH's own receive window closed, and the
|
||||
/// remote blocked on write. The middle step is not true of the library this client uses.
|
||||
/// <c>Renci.SshNet.Channels.Channel.OnData</c> calls its window adjustment <em>before</em> raising
|
||||
/// <c>DataReceived</c> and re-opens the channel window as soon as it drops below one packet, and
|
||||
/// <c>ShellStream</c>'s own handler copies the bytes into a growable buffer without blocking and without a
|
||||
/// ceiling. So a reader that stops reading does not slow the remote down by one byte; it only decides
|
||||
/// whose buffer the flood accumulates in — and SSH.NET's grows by doubling and does not give the memory
|
||||
/// back until the stream is disposed. A remote running <c>yes</c> took the process with it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So the pump now reads continuously, which is what keeps SSH.NET's buffer empty, and the backlog it
|
||||
/// reads into is this one, with a hard capacity. When the renderer cannot keep up the <em>oldest</em>
|
||||
/// bytes go rather than the newest, because the newest are the ones somebody is waiting to see: the tail
|
||||
/// of a flood is the prompt that comes back after it. Dropping is announced rather than silent — see
|
||||
/// <see cref="Take"/> — and it is the honest end of the trade, since output arriving faster than the
|
||||
/// terminal can draw it was going to scroll off a 5,000-line scrollback regardless.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The credit window is still here and still does its own job: it bounds what is in flight <em>to the
|
||||
/// renderer</em>, which is the buffer inside the WebView that nothing on this side can see. What changed
|
||||
/// is that it no longer pretends to bound what is in flight from the remote.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class OutputBacklog
|
||||
{
|
||||
/// <summary>
|
||||
/// Default capacity: 4 MiB.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Sixteen times the credit window, so nothing an ordinary burst produces is ever dropped — a
|
||||
/// screenful of dense output is kilobytes, and a build that scrolls for a second is well under this.
|
||||
/// It is a per-session cost, and it is the whole of what a runaway remote can take, which is the
|
||||
/// number worth choosing against: twenty tabs all flooding at once is 80 MiB and survivable, where the
|
||||
/// same twenty unbounded was whatever the machine had.
|
||||
/// </remarks>
|
||||
public const int DefaultCapacityBytes = 4 * 1024 * 1024;
|
||||
|
||||
private readonly Lock gate = new();
|
||||
private readonly Queue<byte[]> segments = new();
|
||||
private readonly int capacityBytes;
|
||||
|
||||
/// <summary>Signalled when output arrives at an empty backlog, and when it is completed.</summary>
|
||||
private TaskCompletionSource available = CreateSignal();
|
||||
|
||||
/// <summary>How much of the head segment has already been taken.</summary>
|
||||
private int head;
|
||||
|
||||
private int buffered;
|
||||
private long droppedSinceTake;
|
||||
private long droppedTotal;
|
||||
private bool completed;
|
||||
|
||||
/// <param name="capacityBytes">How many unsent bytes to hold before dropping the oldest.</param>
|
||||
public OutputBacklog(int capacityBytes = DefaultCapacityBytes)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(capacityBytes, 1);
|
||||
|
||||
this.capacityBytes = capacityBytes;
|
||||
}
|
||||
|
||||
/// <summary>How many unsent bytes are held.</summary>
|
||||
public int Buffered
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return buffered;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>How many bytes this backlog has discarded over its whole life.</summary>
|
||||
public long DroppedTotal
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return droppedTotal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Whether the producer has said there will be no more output.</summary>
|
||||
public bool IsCompleted
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return completed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds output, dropping the oldest already held if that puts the backlog over capacity.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Takes ownership of <paramref name="segment"/>; the caller must not write to it afterwards.
|
||||
/// Appending to a completed backlog does nothing rather than throwing, because a producer finding out
|
||||
/// it has been shut down is a race rather than a mistake.
|
||||
/// </remarks>
|
||||
/// <param name="segment">Bytes read from the remote, in order.</param>
|
||||
public void Append(byte[] segment)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(segment);
|
||||
|
||||
if (segment.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
if (completed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var wasEmpty = buffered == 0;
|
||||
|
||||
segments.Enqueue(segment);
|
||||
buffered += segment.Length;
|
||||
|
||||
// Never the segment just added, however large it is. A read bigger than the whole capacity is
|
||||
// possible if somebody configures one, and a backlog that answered every take with nothing
|
||||
// would stall the session rather than bound it.
|
||||
while (buffered > capacityBytes && segments.Count > 1)
|
||||
{
|
||||
var oldest = segments.Dequeue();
|
||||
var discarded = oldest.Length - head;
|
||||
|
||||
head = 0;
|
||||
buffered -= discarded;
|
||||
droppedSinceTake += discarded;
|
||||
droppedTotal += discarded;
|
||||
}
|
||||
|
||||
// Only on the transition, because a waiter that found bytes here never captured the signal in
|
||||
// the first place — see WaitForOutputAsync, which checks under this same lock. Signalling on
|
||||
// every append would allocate a TaskCompletionSource per read of a session doing megabytes a
|
||||
// second, for a waiter that is not there.
|
||||
if (wasEmpty)
|
||||
{
|
||||
Signal();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves the oldest output held into <paramref name="destination"/>, as much of it as will fit.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Into a caller's span rather than into an array of its own, because this is the hot path: the pump
|
||||
/// writes straight into the frame it is about to send, so a terminal showing a gigabyte costs one copy
|
||||
/// of it here rather than two.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <paramref name="dropped"/> is what makes a drop visible instead of silent. It counts the bytes
|
||||
/// discarded since the previous take, which is exactly the gap sitting immediately before the bytes
|
||||
/// being handed over now. The caller writes a line saying so, and it has to — the alternative is a
|
||||
/// terminal quietly showing a different transcript from the one the remote sent. It is reported even
|
||||
/// when nothing is taken, so a drop at the very end of a session is still announced.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="destination">Where the bytes go, oldest first.</param>
|
||||
/// <param name="dropped">Bytes discarded since the previous take, and zero on the ordinary path.</param>
|
||||
/// <returns>How many bytes were written, which is zero when nothing is held.</returns>
|
||||
public int TakeInto(Span<byte> destination, out long dropped)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
dropped = droppedSinceTake;
|
||||
droppedSinceTake = 0;
|
||||
|
||||
var take = Math.Min(destination.Length, buffered);
|
||||
var offset = 0;
|
||||
|
||||
while (offset < take)
|
||||
{
|
||||
var oldest = segments.Peek();
|
||||
var fromThis = Math.Min(oldest.Length - head, take - offset);
|
||||
|
||||
oldest.AsSpan(head, fromThis).CopyTo(destination[offset..]);
|
||||
|
||||
offset += fromThis;
|
||||
head += fromThis;
|
||||
buffered -= fromThis;
|
||||
|
||||
if (head == oldest.Length)
|
||||
{
|
||||
_ = segments.Dequeue();
|
||||
head = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return take;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Says there will be no more output, releasing anything waiting for some.</summary>
|
||||
public void Complete()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
completed = true;
|
||||
Signal();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Waits until there is output to take, or until there will never be any.</summary>
|
||||
/// <param name="cancellationToken">Abandons the wait.</param>
|
||||
/// <returns><see langword="false"/> once the backlog is completed and empty.</returns>
|
||||
public async ValueTask<bool> WaitForOutputAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
Task signal;
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
if (buffered > 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (completed)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
signal = available.Task;
|
||||
}
|
||||
|
||||
await signal.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Completed inside the lock so a waiter cannot miss the transition, and replaced there too so the
|
||||
/// next wait is on a signal that has not already fired. Continuations run asynchronously, so nothing
|
||||
/// resumes while the lock is held.
|
||||
/// </remarks>
|
||||
private void Signal()
|
||||
{
|
||||
_ = available.TrySetResult();
|
||||
available = CreateSignal();
|
||||
}
|
||||
|
||||
private static TaskCompletionSource CreateSignal() =>
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
@@ -120,6 +120,32 @@ public enum TerminalServerOpcode : byte
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
Clipboard = 8,
|
||||
|
||||
/// <summary>
|
||||
/// A line for the pane saying that output was dropped. Payload is UTF-8, already styled.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Written into the pane exactly where it arrives, which is where the gap is — the socket keeps order
|
||||
/// within a session, so this frame lands between the bytes before the drop and the bytes after it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Its own opcode rather than a few bytes spliced into an <see cref="Output"/> frame</b>, which is
|
||||
/// what it was first, and the reason is that the two are not the same kind of thing. Output is what the
|
||||
/// remote said, and this is what this application says about it. Keeping them apart means the host's
|
||||
/// own sentence is never mistaken for the transcript — by anything reading a frame, and in particular
|
||||
/// by the arithmetic that has to hold if dropping is to stay honest: every byte read from the remote is
|
||||
/// either in an <see cref="Output"/> payload or counted in <c>TerminalSessionPump.BytesDropped</c>, and
|
||||
/// a notice inside the output stream would put this process's words on the left of that equation.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Not acknowledged, and so not part of the credit window: credit accounts for what the remote sent
|
||||
/// and what the renderer has drawn of it. A notice is a few dozen bytes the host chose to add, and
|
||||
/// billing them to a window that is meant to describe the remote would make the number mean two
|
||||
/// things.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
OutputDropped = 9,
|
||||
}
|
||||
|
||||
/// <summary>Frames the renderer sends to the host.</summary>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Buffers;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Threading.Channels;
|
||||
using DodoSSH.Client.Ssh;
|
||||
|
||||
namespace DodoSSH.Client.Terminal;
|
||||
@@ -49,6 +49,16 @@ public sealed class TerminalPumpOptions
|
||||
|
||||
/// <summary>How far behind the renderer may fall, in bytes.</summary>
|
||||
public int WindowBytes { get; init; } = CreditWindow.DefaultWindowBytes;
|
||||
|
||||
/// <summary>
|
||||
/// How much unsent output to hold for a renderer that is behind, before dropping the oldest.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the number that bounds a session's memory, and <see cref="OutputBacklog"/> says at length
|
||||
/// why it rather than <see cref="WindowBytes"/> is the one that does. Larger drops less and shows a
|
||||
/// flood's tail later; smaller drops sooner and keeps the terminal closer to live.
|
||||
/// </remarks>
|
||||
public int BacklogBytes { get; init; } = OutputBacklog.DefaultCapacityBytes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -56,27 +66,45 @@ public sealed class TerminalPumpOptions
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Credit is reserved before reading, never after. That ordering is the whole design: because the pump
|
||||
/// cannot read more than the renderer has room for, the coalescing buffer is bounded by the credit
|
||||
/// window rather than by how fast the remote can talk. Reserving after reading would leave an
|
||||
/// unbounded queue between the socket and the screen, which is the failure mode this exists to
|
||||
/// prevent.
|
||||
/// ◆ <b>The pump reads the shell channel continuously and never stops, which is deliberate rather than
|
||||
/// careless.</b> It used to stop as soon as the renderer fell behind, on the reasoning that a reader
|
||||
/// which does not read closes SSH's receive window, blocks the remote <c>sshd</c> on write, and blocks
|
||||
/// the process producing the output in turn — backpressure all the way to the source, with no custom
|
||||
/// protocol. That reasoning is wrong for this client's SSH library, and wrong in the direction that costs
|
||||
/// memory: SSH.NET re-opens the channel window when bytes <em>arrive</em> rather than when they are
|
||||
/// consumed, and keeps everything it has been handed in a growable array it does not give back. Not
|
||||
/// reading slowed nothing down; it only moved the flood into a buffer with no ceiling.
|
||||
/// <see cref="OutputBacklog"/> carries the full account.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When credit runs out the pump stops reading. SSH's own receive window then closes, the remote
|
||||
/// <c>sshd</c> blocks on write, and the process producing output blocks in turn — backpressure all the
|
||||
/// way to the source, with no custom protocol.
|
||||
/// So there are two bounds now, each enforced where it can be. The backlog bounds what is waiting to be
|
||||
/// sent, by dropping its oldest bytes and saying so. The credit window bounds what has been sent and not
|
||||
/// yet drawn — the buffer inside the WebView that nothing on this side can measure — and the flush loop
|
||||
/// reserves against it before every frame, holding off while there is none. Reading is no longer part of
|
||||
/// either, because reading was never what the remote was waiting on.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class TerminalSessionPump : IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// The most output one frame carries.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Under the 85,000-byte threshold at which the runtime allocates on the large object heap, and that is
|
||||
/// the whole of why the number is here: a frame is allocated per send, and a session doing megabytes a
|
||||
/// second would otherwise put a quarter-megabyte array on the LOH sixty times a second, which is not
|
||||
/// compacted and is collected only with a generation 2 pass. Below the threshold the same traffic is
|
||||
/// generation 0 garbage that costs nothing to collect. It is a ceiling rather than a target — a quiet
|
||||
/// terminal sends whatever it has, which is a few bytes.
|
||||
/// </remarks>
|
||||
private const int MaximumFrameBytes = 64 * 1024;
|
||||
|
||||
private readonly uint sessionId;
|
||||
private readonly ISshShellSession session;
|
||||
private readonly ITerminalTransport transport;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly TerminalPumpOptions options;
|
||||
private readonly Channel<byte[]> pending = Channel.CreateUnbounded<byte[]>(
|
||||
new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
|
||||
private readonly OutputBacklog backlog;
|
||||
|
||||
private readonly CancellationTokenSource lifetime = new();
|
||||
private int disposed;
|
||||
@@ -104,6 +132,7 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
||||
this.options = options ?? new TerminalPumpOptions();
|
||||
|
||||
Credits = new CreditWindow(this.options.WindowBytes);
|
||||
backlog = new OutputBacklog(this.options.BacklogBytes);
|
||||
}
|
||||
|
||||
/// <summary>This session's flow-control window.</summary>
|
||||
@@ -112,6 +141,19 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
||||
/// <summary>Total bytes read from the remote, for the throughput harness.</summary>
|
||||
public long BytesRead { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Total bytes this session discarded because the renderer could not keep up with the remote.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Zero for every ordinary session, and the number to look at first when somebody reports missing
|
||||
/// output. The user is told as it happens — a dropped run puts a line in the pane rather than a gap —
|
||||
/// so this is for a test or a report rather than for the interface.
|
||||
/// </remarks>
|
||||
public long BytesDropped => backlog.DroppedTotal;
|
||||
|
||||
/// <summary>Bytes read from the remote and not yet sent to the renderer.</summary>
|
||||
public int BytesPending => backlog.Buffered;
|
||||
|
||||
/// <summary>Total frames sent to the renderer, for the throughput harness.</summary>
|
||||
public long FramesSent { get; private set; }
|
||||
|
||||
@@ -152,31 +194,7 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
||||
var reader = ReadLoopAsync(linked.Token);
|
||||
var flusher = FlushLoopAsync(linked.Token);
|
||||
|
||||
TerminalSessionOutcome outcome;
|
||||
try
|
||||
{
|
||||
await reader.ConfigureAwait(false);
|
||||
|
||||
// The read loop finished of its own accord, which means the shell is over. Which of the two ways
|
||||
// it is over is not a question asked of the transport now — by now the server has very likely hung
|
||||
// up behind a clean exit, and asking would call `exit` a dropped connection — but of what the shell
|
||||
// latched at the moment it happened. See ISshShellSession.TransportFailure.
|
||||
outcome = session.TransportFailure is { Length: > 0 } failure
|
||||
? new TerminalSessionOutcome(SshSessionEnd.ConnectionLost, failure)
|
||||
: new TerminalSessionOutcome(SshSessionEnd.RemoteClosed, "The remote closed the session.");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
outcome = new TerminalSessionOutcome(SshSessionEnd.ClosedLocally, "The session was closed.");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// A read that threw rather than returning zero. Whatever it was, it was not this client asking —
|
||||
// that arrives as the cancellation above — so it is the connection going out from under the read.
|
||||
outcome = new TerminalSessionOutcome(
|
||||
SshSessionEnd.ConnectionLost,
|
||||
session.TransportFailure is { Length: > 0 } failure ? failure : exception.Message);
|
||||
}
|
||||
var outcome = await ReadUntilItEndsAsync(reader).ConfigureAwait(false);
|
||||
|
||||
// Before the drain and the last frame, so that anything reading this off a completed run — the
|
||||
// workspace's own announcement — cannot see the default.
|
||||
@@ -185,7 +203,17 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
||||
// Stop the flusher, but only after draining what the reader already produced — the last thing
|
||||
// a remote writes is often the most important, and dropping it makes a clean exit look like a
|
||||
// crash.
|
||||
pending.Writer.TryComplete();
|
||||
backlog.Complete();
|
||||
|
||||
// ◆ AND RELEASE THE FLUSHER IF IT IS PARKED ON CREDIT, WHICH IS NOT THE SAME SIGNAL. Completing
|
||||
// the backlog wakes anything waiting for output; it says nothing to a flush loop that has output
|
||||
// and is waiting for the renderer to make room for it. Against a renderer that has stopped
|
||||
// acknowledging — the page's process killed, the window gone — that acknowledgement never comes,
|
||||
// and without this the await below would never return: the shell would have ended, the tab would
|
||||
// never be told, and the session would never be torn down. Resetting rather than waiting is
|
||||
// right because the window describes a renderer's remaining room and there is nothing left to
|
||||
// protect it from: what follows is one backlog's worth of tail and then the session is over.
|
||||
Credits.Reset();
|
||||
|
||||
try
|
||||
{
|
||||
@@ -203,6 +231,42 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the read loop to finish, and says which of the three ways it did.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Split out of <see cref="RunAsync"/> for length rather than for reuse, and it is the half of that
|
||||
/// method with no side effects: three branches, one answer, nothing touched.
|
||||
/// </remarks>
|
||||
/// <param name="reader">The read loop's task.</param>
|
||||
private async Task<TerminalSessionOutcome> ReadUntilItEndsAsync(Task reader)
|
||||
{
|
||||
try
|
||||
{
|
||||
await reader.ConfigureAwait(false);
|
||||
|
||||
// The read loop finished of its own accord, which means the shell is over. Which of the two ways
|
||||
// it is over is not a question asked of the transport now — by now the server has very likely hung
|
||||
// up behind a clean exit, and asking would call `exit` a dropped connection — but of what the shell
|
||||
// latched at the moment it happened. See ISshShellSession.TransportFailure.
|
||||
return session.TransportFailure is { Length: > 0 } failure
|
||||
? new TerminalSessionOutcome(SshSessionEnd.ConnectionLost, failure)
|
||||
: new TerminalSessionOutcome(SshSessionEnd.RemoteClosed, "The remote closed the session.");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return new TerminalSessionOutcome(SshSessionEnd.ClosedLocally, "The session was closed.");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// A read that threw rather than returning zero. Whatever it was, it was not this client asking —
|
||||
// that arrives as the cancellation above — so it is the connection going out from under the read.
|
||||
return new TerminalSessionOutcome(
|
||||
SshSessionEnd.ConnectionLost,
|
||||
session.TransportFailure is { Length: > 0 } failure ? failure : exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Forwards keystrokes to the remote.</summary>
|
||||
public ValueTask WriteInputAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken) =>
|
||||
session.WriteAsync(data, cancellationToken);
|
||||
@@ -227,44 +291,37 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
||||
// Unblocks anything waiting on credit that will now never be acknowledged.
|
||||
Credits.Reset();
|
||||
|
||||
pending.Writer.TryComplete();
|
||||
backlog.Complete();
|
||||
lifetime.Dispose();
|
||||
|
||||
await session.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// No credit is consulted here, and the class remark says at length why: the remote is not waiting on
|
||||
/// this loop, so pausing it buys nothing and costs a buffer inside SSH.NET that grows without bound.
|
||||
/// What this loop owes is to keep that buffer empty, which means reading as fast as the channel
|
||||
/// delivers and putting the result somewhere with a ceiling.
|
||||
/// </remarks>
|
||||
private async Task ReadLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(options.ReadBufferBytes);
|
||||
|
||||
try
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
while (true)
|
||||
{
|
||||
await Credits.WaitForCreditAsync(cancellationToken).ConfigureAwait(false);
|
||||
// Thrown rather than tested in the loop condition, and the difference is the whole of how
|
||||
// this session's ending is described. RunAsync reads a read loop that RETURNS as
|
||||
// the remote having closed the channel, and one that throws OperationCanceledException as
|
||||
// this client having ended it. A condition on the token would leave by the first door for
|
||||
// a reason that belongs to the second, and a tab the user closed would report itself as a
|
||||
// shell that exited on its own.
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var granted = Credits.TryReserve(options.ReadBufferBytes);
|
||||
if (granted == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
int read;
|
||||
try
|
||||
{
|
||||
read = await session
|
||||
.ReadAsync(buffer.AsMemory(0, granted), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Credits.Return(granted);
|
||||
throw;
|
||||
}
|
||||
|
||||
// Hand back what was reserved but not used, so a short read does not permanently
|
||||
// shrink the window.
|
||||
Credits.Return(granted - read);
|
||||
var read = await session
|
||||
.ReadAsync(buffer.AsMemory(0, options.ReadBufferBytes), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (read == 0)
|
||||
{
|
||||
@@ -272,7 +329,10 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
||||
}
|
||||
|
||||
BytesRead += read;
|
||||
await pending.Writer.WriteAsync(buffer[..read], cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Copied out of the rented buffer, because the backlog holds it until the renderer has
|
||||
// room and the next read is already about to overwrite this one.
|
||||
backlog.Append(buffer[..read]);
|
||||
}
|
||||
}
|
||||
finally
|
||||
@@ -283,65 +343,160 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
||||
|
||||
private async Task FlushLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var segments = new List<byte[]>();
|
||||
|
||||
while (await pending.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
while (await backlog.WaitForOutputAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (!pending.Reader.TryRead(out var first))
|
||||
if (backlog.IsCompleted)
|
||||
{
|
||||
// The reader has finished and what is left is the tail, which goes out regardless of
|
||||
// credit. Leaving by this door rather than going round again is load-bearing: with the
|
||||
// backlog complete, SendWhatCreditAllowsAsync declines to wait for an acknowledgement
|
||||
// that may never come, so a renderer that has stopped would put this loop into a spin —
|
||||
// output to send, no credit to send it with, and a wait for more output that returns at
|
||||
// once because there is some.
|
||||
break;
|
||||
}
|
||||
|
||||
// Coalesce for one frame's worth of time before the first send, so a burst arriving at an idle
|
||||
// terminal becomes a single write. Under sustained output the loop below never comes back
|
||||
// here, and does not need to: credit is what paces it then, and a frame is as large as the
|
||||
// renderer has room for.
|
||||
await Task.Delay(options.FlushInterval, clock, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await SendWhatCreditAllowsAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await DrainAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Sends until the backlog is empty or the renderer has no room left.</summary>
|
||||
private async Task SendWhatCreditAllowsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var wanted = Math.Min(backlog.Buffered, MaximumFrameBytes);
|
||||
if (wanted == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var granted = Credits.TryReserve(wanted);
|
||||
|
||||
if (granted == 0)
|
||||
{
|
||||
// The renderer is behind. Nothing more goes out until it acknowledges, and meanwhile the
|
||||
// backlog holds what it can and drops its oldest for what it cannot — which is the whole
|
||||
// of how this session's memory stays bounded.
|
||||
if (backlog.IsCompleted)
|
||||
{
|
||||
// Except at the end, where waiting on an acknowledgement that may never come would
|
||||
// hang the session's teardown behind a renderer that has already gone.
|
||||
return;
|
||||
}
|
||||
|
||||
await Credits.WaitForCreditAsync(cancellationToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
segments.Clear();
|
||||
segments.Add(first);
|
||||
var sent = await SendFromBacklogAsync(granted, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Coalesce for one frame's worth of time, then send everything at once. Whatever the
|
||||
// remote produced in that window becomes a single write to the terminal.
|
||||
await Task.Delay(options.FlushInterval, clock, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
while (pending.Reader.TryRead(out var more))
|
||||
if (granted > sent)
|
||||
{
|
||||
segments.Add(more);
|
||||
// Whatever was reserved and not used, so a backlog that shrank between the reservation
|
||||
// and the take does not permanently narrow the window. Guarded rather than called with a
|
||||
// zero, because returning credit wakes every waiter and replaces the signal they were
|
||||
// waiting on, and this is the hot path.
|
||||
Credits.Return(granted - sent);
|
||||
}
|
||||
|
||||
await SendOutputAsync(segments, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// The channel completed. Anything the reader wrote before finishing still has to go out.
|
||||
segments.Clear();
|
||||
while (pending.Reader.TryRead(out var trailing))
|
||||
{
|
||||
segments.Add(trailing);
|
||||
}
|
||||
|
||||
if (segments.Count > 0)
|
||||
{
|
||||
await SendOutputAsync(segments, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask SendOutputAsync(List<byte[]> segments, CancellationToken cancellationToken)
|
||||
/// <summary>
|
||||
/// Puts the last of the output out, regardless of credit.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The session is over by the time this runs, so there is no renderer left to protect from a flood —
|
||||
/// and what remains is bounded by the backlog's own capacity rather than by anything the remote can
|
||||
/// still do. Waiting on credit here instead would mean a shell that exited against a renderer which
|
||||
/// had gone never finished tearing down.
|
||||
/// </remarks>
|
||||
private async Task DrainAsync()
|
||||
{
|
||||
var total = 0;
|
||||
foreach (var segment in segments)
|
||||
while (await SendFromBacklogAsync(MaximumFrameBytes, CancellationToken.None)
|
||||
.ConfigureAwait(false) > 0)
|
||||
{
|
||||
total += segment.Length;
|
||||
// Until it is empty.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves up to <paramref name="maxBytes"/> out of the backlog and onto the wire.
|
||||
/// </summary>
|
||||
/// <returns>How many bytes of output were sent, which is zero once the backlog is empty.</returns>
|
||||
private async ValueTask<int> SendFromBacklogAsync(int maxBytes, CancellationToken cancellationToken)
|
||||
{
|
||||
// Taken straight into the frame, header space and all. A second buffer here would be an
|
||||
// allocation and a copy of every byte the terminal ever shows.
|
||||
var frame = new byte[TerminalFrame.HeaderLength + maxBytes];
|
||||
var taken = backlog.TakeInto(frame.AsSpan(TerminalFrame.HeaderLength), out var dropped);
|
||||
|
||||
if (dropped > 0)
|
||||
{
|
||||
// Sent immediately before the bytes it explains, and under its own opcode — see
|
||||
// TerminalServerOpcode.OutputDropped for why this is not simply spliced into the output.
|
||||
await SendAsync(TerminalServerOpcode.OutputDropped, DroppedNotice(dropped), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (taken == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var frame = new byte[TerminalFrame.HeaderLength + total];
|
||||
TerminalFrame.Write(frame, (byte)TerminalServerOpcode.Output, sessionId, default);
|
||||
|
||||
var offset = TerminalFrame.HeaderLength;
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
segment.CopyTo(frame, offset);
|
||||
offset += segment.Length;
|
||||
}
|
||||
|
||||
FramesSent++;
|
||||
await transport.SendAsync(frame, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await transport
|
||||
.SendAsync(frame.AsMemory(0, TerminalFrame.HeaderLength + taken), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return taken;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The line a user sees where output was dropped.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// It opens with a reset rather than the colour, and that is the load-bearing part: the bytes were cut
|
||||
/// at an arbitrary offset, so whatever escape sequence the remote was in the middle of has been left
|
||||
/// half-said. Resetting first means the notice reads as a notice and the output after it starts from a
|
||||
/// known state, instead of inheriting a colour or a mode from a sequence nobody finished.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Dim grey, matching the renderer's own banners for a session that closed and a view that
|
||||
/// reconnected — this is the same kind of thing, a sentence from the application rather than from the
|
||||
/// remote, and it should not read like part of the transcript.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static byte[] DroppedNotice(long dropped) =>
|
||||
Encoding.UTF8.GetBytes(string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"\u001b[0m\r\n\u001b[38;5;244m── {Sized(dropped)} of output was dropped; "
|
||||
+ $"the terminal could not keep up ──\u001b[0m\r\n"));
|
||||
|
||||
/// <remarks>
|
||||
/// One decimal place from a megabyte up, because the number is an order of magnitude rather than an
|
||||
/// accounting: what a person needs from it is whether they lost a screenful or a session's worth.
|
||||
/// </remarks>
|
||||
private static string Sized(long bytes) => bytes switch
|
||||
{
|
||||
< 1024 => string.Create(CultureInfo.CurrentCulture, $"{bytes} bytes"),
|
||||
< 1024 * 1024 => string.Create(CultureInfo.CurrentCulture, $"{bytes / 1024.0:0.#} KB"),
|
||||
< 1024L * 1024 * 1024 => string.Create(CultureInfo.CurrentCulture, $"{bytes / (1024.0 * 1024):0.#} MB"),
|
||||
_ => string.Create(CultureInfo.CurrentCulture, $"{bytes / (1024.0 * 1024 * 1024):0.#} GB"),
|
||||
};
|
||||
|
||||
private async ValueTask SendAsync(
|
||||
TerminalServerOpcode opcode,
|
||||
ReadOnlyMemory<byte> payload,
|
||||
|
||||
@@ -209,11 +209,12 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Not <c>sessions.Count</c>, which over-reports. Nothing removes an entry when the remote closes
|
||||
/// the channel on its own — <see cref="RunSessionAsync"/> only drops the renderer registration — so
|
||||
/// a session whose shell exited half an hour ago is still in the dictionary. A completed
|
||||
/// <c>Run</c> task is what "the shell is gone" actually looks like: the pump's loops have finished
|
||||
/// and it has already sent <c>SessionClosed</c> to the renderer.
|
||||
/// Not <c>sessions.Count</c>, which over-reports. Nothing removes an entry when the remote closes the
|
||||
/// channel on its own — <see cref="ReleaseAsync"/> gives back what that session was holding but leaves
|
||||
/// the entry, so the tab can go on saying it ended — and a session whose shell exited half an hour ago
|
||||
/// is therefore still in the dictionary. A completed <c>Run</c> task is what "the shell is gone"
|
||||
/// actually looks like: the pump's loops have finished and it has already sent <c>SessionClosed</c> to
|
||||
/// the renderer.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This exists because the shell shows the number on the unlock screen, and a lock screen that
|
||||
@@ -642,6 +643,13 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
/// A session id this workspace does not know is ignored rather than throwing. The caller is a tab
|
||||
/// strip, and a tab that closed while a key was in flight is ordinary rather than exceptional.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A session whose shell has <em>ended</em> is ignored the same way, and has to be: the tab is still on
|
||||
/// screen and still selectable, so an accessory key pressed at one is as ordinary as a key pressed at a
|
||||
/// tab that has closed. It is also the one of the two that would throw rather than fail quietly, since
|
||||
/// <see cref="ReleaseAsync"/> disposes the shell channel the moment the remote hangs up. Liveness
|
||||
/// rather than mere presence, which is the same question <see cref="IsSessionLive"/> answers.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="sessionId">The terminal to type into.</param>
|
||||
/// <param name="data">Raw bytes, already encoded as the remote expects them.</param>
|
||||
@@ -655,7 +663,9 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
|
||||
lock (sessionGate)
|
||||
{
|
||||
pump = sessions.TryGetValue(sessionId, out var session) ? session.Pump : null;
|
||||
pump = sessions.TryGetValue(sessionId, out var session) && !session.Run.IsCompleted
|
||||
? session.Pump
|
||||
: null;
|
||||
}
|
||||
|
||||
return pump is null ? ValueTask.CompletedTask : pump.WriteInputAsync(data, cancellationToken);
|
||||
@@ -848,14 +858,79 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
// tab, the remote hanging up, and the process shutting down. Every one of them arrives here as
|
||||
// the pump unwinding, which is why CloseSessionAsync needs no call of its own — and why this
|
||||
// must not do any work: it is running on a thread-pool thread inside DisposeAsync's loop when
|
||||
// the application is closing.
|
||||
// the application is closing, and everything inside a finally is time the run task has not
|
||||
// completed yet, which is time LiveSessionCount still counts this session as running.
|
||||
//
|
||||
// SessionEnded is deliberately NOT raised from here, and it used to be — see
|
||||
// AnnounceEndedAsync for what was wrong with that.
|
||||
// Neither SessionEnded nor the release of what the session held is done from here, and both
|
||||
// used to be candidates for it — see AnnounceEndedAsync, which is where they ended up and why.
|
||||
ConnectionLog?.Closed(sessionId, clock.GetUtcNow());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gives back everything a session that ended on its own was still holding.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>Nothing used to do this, and a shell that exited went on costing what a running one costs.</b>
|
||||
/// The entry stayed in <see cref="sessions"/> until the user closed the tab, and with it an undisposed
|
||||
/// <see cref="ISshConnection"/> — an authenticated socket to the remote, its cipher state, and a
|
||||
/// keep-alive still putting a packet on the wire every thirty seconds — plus the pump and, underneath
|
||||
/// it, SSH.NET's read buffer at whatever size the session's heaviest moment had grown it to. A tab
|
||||
/// somebody typed <c>exit</c> into an hour ago and left open was indistinguishable from a live one in
|
||||
/// everything except what it drew, and a working day of those is the memory a user actually notices.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The dictionary entry stays even so, and that is not an oversight.</b>
|
||||
/// <see cref="LiveSessionCount"/>, <see cref="IsSessionLive"/> and
|
||||
/// <see cref="AnnounceEndedAsync"/>'s containment check are all written against it — a session that
|
||||
/// ended still has to be able to say so, and a tab that shows ENDED and offers a reconnect is reading
|
||||
/// exactly that. What goes is everything the entry was holding that costs anything; what is left is
|
||||
/// three references and a completed task.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Skipped entirely when the entry has already been removed, which is precisely the deliberate path:
|
||||
/// <see cref="CloseSessionAsync"/> takes the entry out before it disposes anything, so the two can
|
||||
/// never both be tearing the same session down. That is also what makes this a no-op during
|
||||
/// <see cref="DisposeAsync"/>, which closes every session by that route before anything here could run.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every failure is swallowed. The caller is a fire-and-forget continuation with nobody to hand a fault
|
||||
/// to, and the shell it would be about is already gone.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task ReleaseAsync(uint sessionId)
|
||||
{
|
||||
LiveSession? session;
|
||||
|
||||
lock (sessionGate)
|
||||
{
|
||||
if (!sessions.TryGetValue(sessionId, out session))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// The connection goes even if the pump's disposal throws, for the reason CloseSessionAsync
|
||||
// gives at the same pair of calls: an undisposed ISshConnection is a live authenticated
|
||||
// channel this process has forgotten about.
|
||||
try
|
||||
{
|
||||
await session.Pump.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await session.Connection.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
// Best-effort. The shell is already gone; nothing a caller could do with this.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Announces a session's end once its run task has actually completed.</summary>
|
||||
/// <param name="sessionId">The session, as the renderer knows it.</param>
|
||||
/// <param name="pump">
|
||||
@@ -898,12 +973,25 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
||||
announce = sessions.ContainsKey(sessionId);
|
||||
}
|
||||
|
||||
if (announce)
|
||||
if (!announce)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Read after the await, which is the only point at which the pump has settled it — see
|
||||
// TerminalSessionPump.Outcome.
|
||||
SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId, pump.Outcome));
|
||||
}
|
||||
finally
|
||||
{
|
||||
// After the announcement rather than before it, and in a finally so a handler that throws
|
||||
// cannot leave a connection open. Tearing down an SSH connection is a socket closing and a
|
||||
// library shutting down its listener; doing that first would put it between the shell ending
|
||||
// and the tab being told, which is latency on the one thing the user is watching for.
|
||||
await ReleaseAsync(sessionId).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The address as dialled, for the log.</summary>
|
||||
|
||||
@@ -51,6 +51,18 @@ internal static class LayoutHarness
|
||||
/// </remarks>
|
||||
internal const double HostDrawerWidth = 320;
|
||||
|
||||
/// <summary>The splash window's fixed width, from <c>SplashWindow.axaml</c>.</summary>
|
||||
/// <remarks>
|
||||
/// Its height is deliberately absent from this list, because the window has none to state: it is
|
||||
/// <c>SizeToContent="Height"</c> so that a failure message of any length is readable rather than
|
||||
/// clipped. <see cref="SplashMinimumHeight"/> is the floor under the ordinary one-line case, and
|
||||
/// <c>SplashCardTests</c> pins all three against the XAML.
|
||||
/// </remarks>
|
||||
internal const double SplashWidth = 420;
|
||||
|
||||
/// <inheritdoc cref="SplashWidth" />
|
||||
internal const double SplashMinimumHeight = 160;
|
||||
|
||||
/// <summary>The nav rail's fixed width, from <c>NavRail.axaml</c>.</summary>
|
||||
/// <remarks>v5b: 190 became 255, the design's own number rather than this bar's old approximation.</remarks>
|
||||
internal const double NavRailWidth = 255;
|
||||
|
||||
@@ -942,6 +942,49 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
await MeasureConnectingAsync(faults => faults.ShouldBeEmpty("with a refusal to explain"), tab);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The card is the one place the shell says what it is dialling <em>before</em> anything answers, and on a
|
||||
/// refusal it is the only copy of that on screen — so both lines are there to be taken away, and a
|
||||
/// <c>TextBlock</c> in Avalonia is a drawing of a string rather than a string. Same change, same argument,
|
||||
/// as the session column's own address; see <c>SessionSidebarTests</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Asserted on values long enough to be trimmed, which is the half that could quietly break: an ellipsis
|
||||
/// is a drawing and not a shorter string, so select-all has to reach the whole name and the whole address
|
||||
/// rather than the part that happened to fit inside the card's 460 pixels. Values that fit would pass
|
||||
/// whether that were true or not.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheConnectingCardsNameAndAddressSelectAndCopyWhole()
|
||||
{
|
||||
const string label = "customer-production-database-01.eu-west-1.internal.example.company";
|
||||
const string address = "a-very-long-deploy-account@db-primary.eu-west-1.internal.example:22022";
|
||||
|
||||
await OnTheConnectingCardAsync(
|
||||
card =>
|
||||
{
|
||||
// Found by their text rather than by position: the order is the visual tree's, and a third
|
||||
// selectable block is already in there — the refusal reason, hidden while this tab is only
|
||||
// connecting. Single rather than First, so a duplicate would be a question rather than a
|
||||
// silent pick.
|
||||
var takeable = card.GetVisualDescendants().OfType<SelectableTextBlock>().ToList();
|
||||
|
||||
var name = takeable.Single(block => block.Text == label);
|
||||
var endpoint = takeable.Single(block => block.Text == address);
|
||||
|
||||
name.SelectAll();
|
||||
name.SelectedText.ShouldBe(label, "the ellipsis is a drawing, not a shorter string");
|
||||
name.CanCopy.ShouldBeTrue("Ctrl+C and the right-click Copy item are both gated on this");
|
||||
|
||||
endpoint.SelectAll();
|
||||
endpoint.SelectedText.ShouldBe(address, "the ellipsis is a drawing, not a shorter string");
|
||||
endpoint.CanCopy.ShouldBeTrue("Ctrl+C and the right-click Copy item are both gated on this");
|
||||
},
|
||||
new TerminalTabViewModel(label, address));
|
||||
}
|
||||
|
||||
// ---- The logs screen ----
|
||||
|
||||
[Fact]
|
||||
@@ -2043,6 +2086,40 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
},
|
||||
Token);
|
||||
|
||||
/// <summary>Lays the connecting card out and hands it over, rather than measuring it.</summary>
|
||||
/// <remarks>
|
||||
/// The same rectangle <see cref="MeasureConnectingAsync"/> uses and for the same reasons — see its
|
||||
/// remark. Separate because the claim is about a control's own state rather than about where anything was
|
||||
/// arranged, and threading an assertion over the card through a method whose parameter is a fault list
|
||||
/// would make both callers harder to read than two methods are.
|
||||
/// </remarks>
|
||||
private Task OnTheConnectingCardAsync(Action<ConnectingCard> body, TerminalTabViewModel tab) =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
shell.State = ShellState.Unlocked;
|
||||
shell.Tabs.Clear();
|
||||
shell.Tabs.Add(tab);
|
||||
shell.SelectedTab = tab;
|
||||
|
||||
var card = new ConnectingCard { DataContext = shell };
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
card,
|
||||
LayoutHarness.SessionScreenWidth(sidebarVisible: true),
|
||||
LayoutHarness.SessionScreenHeight);
|
||||
|
||||
try
|
||||
{
|
||||
body(card);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
|
||||
/// <summary>Lays the host-key decision out in the rectangle it is drawn over.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
using Avalonia.Controls;
|
||||
using DodoSSH.Client.App.Views;
|
||||
|
||||
namespace DodoSSH.Client.App.Layout.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The splash, and the one thing about it that can go wrong.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Three stacked elements in a fixed-width window is not a layout with much to get wrong — except in one
|
||||
/// state. <c>SplashWindow.CouldNotStart</c> carries an exception's own message, whose length nothing in this
|
||||
/// application decides, and that message is the whole reason that window is still on screen. Inside a fixed
|
||||
/// height it would wrap and be clipped at exactly the wrong moment.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So what is measured here is that the message grows the card, and that the window is declared in a way
|
||||
/// that lets the growth through. <c>LayoutHarness.Unreachable</c> is no help: it reports interactive controls
|
||||
/// arranged where they cannot be used, and the splash has none — which is why these two assert on measured
|
||||
/// heights instead.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SplashCardTests
|
||||
{
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <summary>A message about as long as a real one gets: a path, a reason and a suggestion.</summary>
|
||||
private const string LongFailure =
|
||||
"The process cannot access the file 'C:\\Users\\somebody\\AppData\\Local\\DodoSSH\\cache.db' "
|
||||
+ "because it is being used by another process, and this build cannot open a second copy of it.";
|
||||
|
||||
/// <remarks>
|
||||
/// Pins the two constants and the mechanism against the XAML, the way
|
||||
/// <c>LayoutHarnessTests.TheHarnessMeasuresTheSizeTheWindowDeclares</c> pins the main window's minimum.
|
||||
/// <c>SizeToContent</c> is the one that matters: a window that lost it would clip the message and this
|
||||
/// suite's other test would go on passing, because the card would still be measuring the taller size it
|
||||
/// was never given.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheSplashIsDeclaredTheWayTheHarnessMeasuresIt()
|
||||
{
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var window = new SplashWindow();
|
||||
|
||||
try
|
||||
{
|
||||
window.Width.ShouldBe(LayoutHarness.SplashWidth);
|
||||
window.MinHeight.ShouldBe(LayoutHarness.SplashMinimumHeight);
|
||||
window.SizeToContent.ShouldBe(
|
||||
SizeToContent.Height,
|
||||
"a fixed height would clip a long failure message");
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The ordinary state has to fit the floor, or the window opens taller than its own minimum for the one
|
||||
/// line it normally shows — which would make <see cref="LayoutHarness.SplashMinimumHeight"/> a number
|
||||
/// that never applies to anything.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheStartingMessageFitsTheHeightTheSplashOpensAt()
|
||||
{
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() => MessageHeight(card => { }).ShouldBeLessThanOrEqualTo(
|
||||
LayoutHarness.SplashMinimumHeight,
|
||||
"the splash opens at its minimum and the one line it starts with has to fit"),
|
||||
Token);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// ◆ The test with something behind it. A message that wraps to three lines has to make the card ask for
|
||||
/// more room; if it did not, the text would be sitting under the bottom edge of the window and the only
|
||||
/// thing the splash exists to carry at that point would be the part nobody could read.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ALongFailureMessageAsksForMoreRoomRatherThanBeingClipped()
|
||||
{
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var starting = MessageHeight(card => { });
|
||||
var failed = MessageHeight(card => card.CouldNotStart(LongFailure));
|
||||
|
||||
failed.ShouldBeGreaterThan(
|
||||
starting,
|
||||
"the message wraps, so the card has to want the height the window then gives it");
|
||||
},
|
||||
Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What one card wants to be, at the splash's width, after the caller has had its say.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Hosted in a window before being measured, because the heading and the hint take their size and their
|
||||
/// wrapping from <c>App.axaml</c> — a card measured detached would be measuring unstyled text. Measured
|
||||
/// again afterwards against an unbounded height, which is what the real window's <c>SizeToContent</c>
|
||||
/// gives it and what the host window's own fixed height would otherwise clamp away.
|
||||
/// </remarks>
|
||||
private static double MessageHeight(Action<SplashCard> say)
|
||||
{
|
||||
var card = new SplashCard();
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
card, LayoutHarness.SplashWidth, LayoutHarness.SplashMinimumHeight);
|
||||
|
||||
try
|
||||
{
|
||||
say(card);
|
||||
|
||||
card.Measure(new Avalonia.Size(LayoutHarness.SplashWidth, double.PositiveInfinity));
|
||||
|
||||
return card.DesiredSize.Height;
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,31 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory, ISftpSes
|
||||
/// </remarks>
|
||||
internal List<SshConnectionRequest> SftpRequests { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// The furthest phase a failing attempt reports before <see cref="Failure"/> is thrown, or null for one
|
||||
/// that never reports any.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>It is what decides whether the shell goes and probes the endpoint.</b> A connection that stopped
|
||||
/// while reaching is worth asking a socket about; one that got a host key has plainly reached the machine,
|
||||
/// and one that never reported a phase never dialled at all. See <c>VaultViewModel.ExplainAsync</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Defaults to <see cref="SshConnectionPhase.Reaching"/>, which is what this fake reported
|
||||
/// unconditionally before the field existed, so every test written against it goes on meaning what it did.
|
||||
/// Null models the real factory throwing out of <c>BuildConnectionInfo</c> — a private key that will not
|
||||
/// parse — which happens before it reports anything.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal SshConnectionPhase? FailAfter { get; set; } = SshConnectionPhase.Reaching;
|
||||
|
||||
/// <summary>Every phase up to and including <paramref name="furthest"/>, in the order they happen.</summary>
|
||||
private static IEnumerable<SshConnectionPhase> PhasesUpTo(SshConnectionPhase? furthest) =>
|
||||
furthest is { } limit
|
||||
? Enum.GetValues<SshConnectionPhase>().Where(phase => phase <= limit)
|
||||
: [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ISshConnection> ConnectAsync(
|
||||
SshConnectionRequest request,
|
||||
@@ -56,7 +81,12 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory, ISftpSes
|
||||
// Before the gate rather than after it, which is what makes this fake useful for the connecting
|
||||
// card: a test that holds Gate open is a connection stuck partway through, and the step list has to
|
||||
// show it stuck on a named step rather than on none.
|
||||
progress?.Report(SshConnectionPhase.Reaching);
|
||||
//
|
||||
// How far a failing attempt gets is FailAfter's to say; a succeeding one is always reaching by now.
|
||||
foreach (var phase in Failure is null ? [SshConnectionPhase.Reaching] : PhasesUpTo(FailAfter))
|
||||
{
|
||||
progress?.Report(phase);
|
||||
}
|
||||
|
||||
if (Gate is { } gate)
|
||||
{
|
||||
@@ -419,3 +449,42 @@ internal sealed class FakeSshShellSession : ISshShellSession
|
||||
closed.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A reachability probe that answers from a script instead of from the network.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>Every shell in this suite is given one, and that is not only about speed.</b> The real probe resolves
|
||||
/// a name and opens a socket — see <see cref="SshReachabilityProbe"/> — so a fixture without this would put a
|
||||
/// live DNS lookup for <c>db.internal</c> and a live TCP connect in the middle of a unit suite, on every test
|
||||
/// where a connection fails. That is slow, it needs a network, and it is worse than either: <c>db.internal</c>
|
||||
/// resolves on any machine whose resolver has a search domain that makes it resolve, so the sentence the
|
||||
/// assertions read would depend on whose network the suite was run from.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="Summary"/> defaults to empty, which is what keeps the tests that predate the probe asserting
|
||||
/// the exact message they always did: an empty finding appends nothing, so those cases go on describing a
|
||||
/// failure and nothing else. A test that is about the diagnosis sets it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class FakeReachabilityProbe : ISshReachabilityProbe
|
||||
{
|
||||
/// <summary>What every probe comes back with. Empty adds nothing to the failure it explains.</summary>
|
||||
internal string Summary { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>What it was asked about, in order, so a test can assert it was not asked at all.</summary>
|
||||
internal List<string> Probed { get; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<SshReachabilityReport> ProbeAsync(
|
||||
string host,
|
||||
int port,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Probed.Add($"{host}:{port}");
|
||||
|
||||
return ValueTask.FromResult(
|
||||
new SshReachabilityReport(SshReach.Open, host, port, host, TimeSpan.Zero, Summary));
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,15 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
/// </remarks>
|
||||
private readonly FakeSshConnectionFactory ssh = new();
|
||||
|
||||
/// <summary>
|
||||
/// The scripted answer behind every "why did that not connect" in this file.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Silent by default — see <see cref="FakeReachabilityProbe.Summary"/> — so the cases written before the
|
||||
/// diagnosis existed go on asserting the exact failure message they always did.
|
||||
/// </remarks>
|
||||
private readonly FakeReachabilityProbe reachability = new();
|
||||
|
||||
private int signInAttempts;
|
||||
|
||||
/// <summary>How many times a shell has tried to resume a remembered sign-in, and with what.</summary>
|
||||
@@ -212,7 +221,12 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
{
|
||||
save = onChanged;
|
||||
return new StopWatching(() => save = null);
|
||||
});
|
||||
},
|
||||
|
||||
// Scripted rather than real, so that a failed connection in any test in this file does not go
|
||||
// and do a DNS lookup and open a socket. See FakeReachabilityProbe for why that matters beyond
|
||||
// the wall clock.
|
||||
reachability: reachability);
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
@@ -2085,6 +2099,82 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
"the session that was replaced should have been released with its pane");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ THE ONE THIS EXISTS FOR: a reconnect that dialled a different machine of the same name. Nothing in
|
||||
/// this application stops two hosts being called the same thing — a team's vault beside a personal one
|
||||
/// makes it ordinary, and "prod-db" is what a database server gets called in both — and a tab used to
|
||||
/// find its host by that name. A dropped session on one machine then came back as a live shell on the
|
||||
/// other, with the tab, the address and the status bar all still naming the first.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Asserted on the address actually dialled rather than on anything the tab says about itself. The two
|
||||
/// rows agree on every string a tab carries, which is exactly why this went unnoticed: a test written
|
||||
/// against the label would have passed on the day the wrong machine was being opened.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ReconnectingATabWhoseHostHasANamesake_DialsTheMachineTheTabWasOpenedOn()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await AddKeyAsync(vault, "deploy");
|
||||
|
||||
// A second host under the name the fixture's own already has, on a different machine.
|
||||
await AddHostAsync(vault, "prod-db", "db-replica.internal");
|
||||
|
||||
// Both bound to the key, so neither is diverted to the password sheet: what this asserts is which of
|
||||
// the two gets dialled, not whether either can be. Re-found between the two, because saving a host
|
||||
// rebuilds the list.
|
||||
await BindKeyAsync(vault, Machine(vault, "db.internal"), vault.Keys[0].EntityId);
|
||||
await BindKeyAsync(vault, Machine(vault, "db-replica.internal"), vault.Keys[0].EntityId);
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectToRowCommand.ExecuteAsync(Machine(vault, "db-replica.internal"));
|
||||
|
||||
var tab = shell.Tabs.ShouldHaveSingleItem();
|
||||
ssh.Requests.ShouldHaveSingleItem().Host.ShouldBe("db-replica.internal");
|
||||
|
||||
ssh.Shells[0].Drop("An established connection was aborted by the server.");
|
||||
await EventuallyAsync(() => !tab.IsLive, "the dropped shell should have ended the session");
|
||||
|
||||
shell.CanReconnectSelectedTab.ShouldBeTrue("the machine this tab was opened on is still there");
|
||||
|
||||
await shell.ReconnectTabCommand.ExecuteAsync(tab);
|
||||
|
||||
tab.IsLive.ShouldBeTrue(tab.Status);
|
||||
|
||||
ssh.Requests.Count.ShouldBe(2);
|
||||
ssh.Requests[1].Host.ShouldBe(
|
||||
"db-replica.internal",
|
||||
"a reconnect dials the machine the tab was opened on, not the one that shares its name");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The same mix-up as the test above, where it is read rather than dialled. A green dot is the host list
|
||||
/// answering "am I on this machine", and the name two rows share is not an answer to it — a dot on the
|
||||
/// machine nobody has a shell on claims a session that does not exist, and takes QUICK ACCESS's pins with
|
||||
/// it, since the sidebar offers the pinned folders of whichever row that same match found.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ConnectingToOneOfTwoHostsOfTheSameName_LightsThatOneAlone()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await AddHostAsync(vault, "prod-db", "db-replica.internal");
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
vault.SelectedHost = Machine(vault, "db-replica.internal");
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
shell.Tabs.ShouldHaveSingleItem().IsLive.ShouldBeTrue();
|
||||
|
||||
Machine(vault, "db-replica.internal").IsConnected.ShouldBeTrue();
|
||||
Machine(vault, "db.internal").IsConnected.ShouldBeFalse(
|
||||
"nobody has a shell on this machine; only its name was connected to");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A connection that was refused is worth retrying for the same reason a dropped one is, and the
|
||||
/// commonest refusal — a machine that was not up yet — is the one most likely to succeed a minute later.
|
||||
@@ -2516,6 +2606,84 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
tab.Steps[1].IsRunning.ShouldBeTrue("nothing moved it on after the shell let go of it");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ <b>The reason this exists: "Connection has timed out." is what SSH.NET says for two different
|
||||
/// faults.</b> A machine that never answered and a machine that answered TCP and then never spoke SSH are
|
||||
/// the same five words and nothing else, and they are not the same problem. So a failure that stopped
|
||||
/// while reaching now goes and looks, and what it finds is added to what was said.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The finding is appended rather than substituted, which is the half worth asserting: the exception is
|
||||
/// the fact and the probe is a second measurement, and a test that only checked for the new sentence
|
||||
/// would pass against an implementation that had thrown the original away.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AConnectionThatCouldNotReachItsHost_SaysWhatIsActuallyWrongWithIt()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
ssh.Failure = new InvalidOperationException("Connection has timed out.");
|
||||
reachability.Summary = "Port 22 on db.internal accepts connections.";
|
||||
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
var tab = shell.Tabs.ShouldHaveSingleItem();
|
||||
|
||||
tab.Status.ShouldBe("Connection has timed out. Port 22 on db.internal accepts connections.");
|
||||
reachability.Probed.ShouldHaveSingleItem().ShouldBe("db.internal:22");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Past <see cref="SshConnectionPhase.Reaching"/> the machine has offered a host key, so it is plainly
|
||||
/// reachable and there is nothing a socket could add. A refused password that came back with "the network
|
||||
/// is fine and something is listening" bolted on would be true, useless, and read as though the client
|
||||
/// had not understood the question.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AConnectionRefusedAfterTheHostKey_IsNotProbed()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
ssh.FailAfter = SshConnectionPhase.Authenticating;
|
||||
ssh.Failure = new InvalidOperationException("Permission denied (publickey,password).");
|
||||
reachability.Summary = "this must not be appended";
|
||||
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
shell.Tabs.ShouldHaveSingleItem().Status.ShouldBe("Permission denied (publickey,password).");
|
||||
reachability.Probed.ShouldBeEmpty("the host key proved the machine was reached");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// ◆ <b>The other end of the window, and the one that would state something false.</b> A failure with no
|
||||
/// phase reported at all never dialled — the real factory throws out of <c>BuildConnectionInfo</c> on a
|
||||
/// private key it cannot parse, before the socket is opened. Probing there finds a perfectly healthy port
|
||||
/// and appends "the SSH handshake is what did not finish" to a message about an unreadable key, which is
|
||||
/// a confident wrong answer replacing an accurate one.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AFailureBeforeAnythingWasDialled_IsNotProbed()
|
||||
{
|
||||
var vault = await ReadyToConnectAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
|
||||
ssh.FailAfter = null;
|
||||
ssh.Failure = new InvalidOperationException("The private key could not be read.");
|
||||
reachability.Summary = "this must not be appended";
|
||||
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
shell.Tabs.ShouldHaveSingleItem().Status.ShouldBe("The private key could not be read.");
|
||||
reachability.Probed.ShouldBeEmpty("nothing had been dialled, so there was nothing to ask about");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A refusal has to end up somewhere the user will see it, and by the time one arrives they are quite
|
||||
/// likely looking at another screen — which is exactly what not blocking bought. The tab is that place,
|
||||
@@ -10285,11 +10453,14 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
private static async Task AddHostAsync(VaultViewModel vault, string label)
|
||||
private static async Task AddHostAsync(
|
||||
VaultViewModel vault,
|
||||
string label,
|
||||
string hostname = "db.internal")
|
||||
{
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = label;
|
||||
vault.EditorHostname = "db.internal";
|
||||
vault.EditorHostname = hostname;
|
||||
vault.EditorUsername = "deploy";
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
@@ -10313,6 +10484,14 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
private static HostRowViewModel Host(VaultViewModel vault, string label) =>
|
||||
vault.Hosts.Single(row => string.Equals(row.Label, label, StringComparison.Ordinal));
|
||||
|
||||
/// <summary>The host row for a given machine, for the tests whose rows deliberately share a name.</summary>
|
||||
/// <remarks>
|
||||
/// <see cref="Host"/> cannot be asked when two rows are called the same thing, which is the whole
|
||||
/// situation those tests set up. Re-found rather than held, because saving any host rebuilds the list.
|
||||
/// </remarks>
|
||||
private static HostRowViewModel Machine(VaultViewModel vault, string hostname) =>
|
||||
vault.Hosts.Single(row => string.Equals(row.Host.Hostname, hostname, StringComparison.Ordinal));
|
||||
|
||||
/// <summary>Deletes the selected host: the question, and then the answer to it.</summary>
|
||||
/// <remarks>
|
||||
/// Both halves, because both are what deleting anything now takes — arming on its own changes nothing,
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
using DodoSSH.Client.App.Platform;
|
||||
using DodoSSH.Client.Session;
|
||||
|
||||
namespace DodoSSH.Client.App.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// One desktop instance per profile, and what the second launch does instead of starting.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Both halves are real here: a real lock file in a real temporary profile, and a real named pipe. Neither
|
||||
/// is worth faking — the whole claim being made is about what the operating system does with an exclusive
|
||||
/// handle and a pipe name, and a fake of either would be a test of the fake.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// What is <em>not</em> reachable from here is a second process. Every test below runs in one, so the lock
|
||||
/// is proved against a second <em>handle</em> rather than a second executable, and the window that comes
|
||||
/// forward is a delegate rather than a window. <c>docs/manual-checks.md</c> 16.11 is the other side of that
|
||||
/// line.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SingleInstanceTests : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// A profile of its own per test, which is what makes these safe to run beside a real installation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The pipe name is derived from this directory — see <c>SingleInstance.PipeNameFor</c> — so a unique
|
||||
/// directory is also a unique pipe. Without that, a test here would ring the doorbell of whatever
|
||||
/// DodoSSH the developer happens to have running.
|
||||
/// </remarks>
|
||||
private readonly ClientPaths paths =
|
||||
new(Path.Combine(Path.GetTempPath(), $"dodossh-instance-{Guid.CreateVersion7():N}"));
|
||||
|
||||
/// <summary>
|
||||
/// No patience at all, which is what every test here wants.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The shipped three seconds exist for a lock held by a process on its way out — an update relaunching
|
||||
/// itself, above all — and nothing here is on its way anywhere. Passing zero takes the give-up decision
|
||||
/// on the first attempt, so a refusal costs a millisecond rather than three seconds of the suite.
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan Impatient = TimeSpan.Zero;
|
||||
|
||||
/// <summary>
|
||||
/// How long a doorbell is given to ring before the test says it never will.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ◆ <b>A bound at all, which is the lesson rather than the number.</b> These two awaits were written
|
||||
/// against <c>TestContext.Current.CancellationToken</c> alone, which carries no timeout — so the run
|
||||
/// where a ring was genuinely lost did not fail, it <em>hung</em>, and took the whole CI job with it for
|
||||
/// as long as anybody let it. Twenty seconds is a hundred times what a local pipe needs and a fifth of
|
||||
/// what it costs to notice; a bound that is never reached costs nothing and a missing one costs the
|
||||
/// build.
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan LongEnoughForAPipe = TimeSpan.FromSeconds(20);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(paths.DataDirectory))
|
||||
{
|
||||
Directory.Delete(paths.DataDirectory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheFirstLaunchClaimsTheProfile()
|
||||
{
|
||||
using var claim = SingleInstance.Claim(paths, Impatient);
|
||||
|
||||
claim.ShouldNotBeNull();
|
||||
File.Exists(paths.InstanceLockFile).ShouldBeTrue("the claim is a handle on a real file");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The point of the whole exercise. Nobody is listening in this test, so the second claim takes the
|
||||
/// give-up path rather than the ask-and-leave one — which is why it is handed no patience at all.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void ASecondLaunchIsRefused()
|
||||
{
|
||||
using var first = SingleInstance.Claim(paths, Impatient);
|
||||
first.ShouldNotBeNull();
|
||||
|
||||
SingleInstance.Claim(paths, Impatient).ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// ◆ There is no such thing as a stale lock here, and this is what says so. The handle is closed by the
|
||||
/// runtime when it is disposed and by the kernel when the process ends however it ends — which is the
|
||||
/// reason this is a file rather than a named mutex, whose abandonment is a state to be handled.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void TheProfileIsClaimableAgainOnceTheFirstInstanceLetsGo()
|
||||
{
|
||||
var first = SingleInstance.Claim(paths, Impatient);
|
||||
first.ShouldNotBeNull();
|
||||
first.Dispose();
|
||||
|
||||
using var second = SingleInstance.Claim(paths, Impatient);
|
||||
|
||||
second.ShouldNotBeNull("the lock went with the handle");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The other half: a refused launch is not a silent one. Somebody who double-clicks the icon and sees
|
||||
/// nothing has been told the application is broken, so the running instance is asked to come forward.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ARefusedLaunchAsksTheRunningInstanceToComeForward()
|
||||
{
|
||||
using var first = SingleInstance.Claim(paths, Impatient);
|
||||
first.ShouldNotBeNull();
|
||||
|
||||
var asked = new TaskCompletionSource();
|
||||
|
||||
await using var doorbell = SingleInstance.Listen(paths, () => asked.TrySetResult());
|
||||
|
||||
SingleInstance.Claim(paths, Impatient).ShouldBeNull("the profile is taken");
|
||||
|
||||
// Awaited rather than polled: the callback comes off a thread pool thread. Bounded explicitly,
|
||||
// because the test's own token is not — see LongEnoughForAPipe.
|
||||
await asked.Task.WaitAsync(LongEnoughForAPipe, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A third and a fourth launch work as well as the second, and this is the test that caught the doorbell
|
||||
/// getting that wrong on Linux and only on Linux. A fresh <c>NamedPipeServerStream</c> per connection
|
||||
/// closes the listening socket between callers there, so the second ring was accepted by nobody while
|
||||
/// the launch that made it was told it had succeeded. See <c>SingleInstance.Doorbell</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Two rings back to back rather than one and a pause, because the gap is the whole defect: a test that
|
||||
/// waited for the first to be answered before making the second would pass against the broken version.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheDoorbellAnswersMoreThanOnce()
|
||||
{
|
||||
using var first = SingleInstance.Claim(paths, Impatient);
|
||||
first.ShouldNotBeNull();
|
||||
|
||||
var rings = 0;
|
||||
var twice = new TaskCompletionSource();
|
||||
|
||||
await using var doorbell = SingleInstance.Listen(
|
||||
paths,
|
||||
() =>
|
||||
{
|
||||
if (Interlocked.Increment(ref rings) == 2)
|
||||
{
|
||||
twice.TrySetResult();
|
||||
}
|
||||
});
|
||||
|
||||
SingleInstance.AskTheRunningOneToComeForward(paths).ShouldBeTrue();
|
||||
SingleInstance.AskTheRunningOneToComeForward(paths).ShouldBeTrue();
|
||||
|
||||
await twice.Task.WaitAsync(LongEnoughForAPipe, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// What the give-up path turns on. A lock held by something that is not listening — a process still
|
||||
/// starting, or one on its way out — answers no, and that is what makes <c>Claim</c> wait and try the
|
||||
/// lock again rather than concluding anything from the silence.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void AskingWithNobodyListening_SaysSo()
|
||||
{
|
||||
SingleInstance.AskTheRunningOneToComeForward(paths).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Two profiles, because a nightly and a release build are installed side by side on purpose and must
|
||||
/// not exclude each other — ADR 0013. The lock file is inside the profile and the pipe name is derived
|
||||
/// from it, so both halves are scoped by the one decision <c>ClientPaths.ForChannel</c> already made.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void TwoProfilesDoNotExcludeEachOther()
|
||||
{
|
||||
var other = new ClientPaths(paths.DataDirectory + ".Nightly");
|
||||
|
||||
try
|
||||
{
|
||||
using var release = SingleInstance.Claim(paths, Impatient);
|
||||
using var nightly = SingleInstance.Claim(other, Impatient);
|
||||
|
||||
release.ShouldNotBeNull();
|
||||
nightly.ShouldNotBeNull("a second profile is a second application");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(other.DataDirectory))
|
||||
{
|
||||
Directory.Delete(other.DataDirectory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,6 +162,84 @@ public sealed class PumpOverRealSshTests(SshServerFixture fixture)
|
||||
"the strip under the terminal prints this sentence, so an empty one is a blank status line");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A remote flooding a renderer that never draws anything is bounded, and the pump keeps reading.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// ◆ THIS IS THE TEST THE OLD DESIGN WOULD FAIL, AND IT NEEDS A REAL SSH.NET UNDERNEATH IT TO SAY SO.
|
||||
/// The pump used to stop reading once a renderer had a credit window's worth of unrendered output, on
|
||||
/// the reasoning that SSH's own receive window would then close and block the remote. It does not:
|
||||
/// <c>Renci.SshNet.Channels.Channel.OnData</c> adjusts the window as bytes <em>arrive</em>, before it
|
||||
/// hands them on, so the remote goes on sending at full speed and <c>ShellStream</c> copies every byte
|
||||
/// into a buffer that doubles and is never given back. Against this test, <c>yes</c> would have been
|
||||
/// read at <c>BytesRead == WindowBytes</c> exactly and then stopped for ever while the process grew.
|
||||
/// A fake shell cannot show that, because a fake hands over bytes when it is asked and a real channel
|
||||
/// hands them over when they arrive.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So the assertion is in two halves and needs both. Reading keeps going, which is what keeps SSH.NET's
|
||||
/// buffer drained; and what this side holds stays inside the backlog, which is what keeps the drained
|
||||
/// bytes from simply piling up here instead. Either alone is satisfied by a design that leaks.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ARunawayRemote_KeepsBeingReadAndStaysInsideTheBacklog()
|
||||
{
|
||||
const int Window = 64 * 1024;
|
||||
const int Backlog = 512 * 1024;
|
||||
|
||||
await using var connection = await ConnectTrustedAsync();
|
||||
|
||||
var shell = await connection.OpenShellAsync(
|
||||
new TerminalSize(100, 30, 1000, 750), TestContext.Current.CancellationToken);
|
||||
|
||||
// Deliberately never acknowledges: this is a renderer that has stopped drawing, which is the whole
|
||||
// condition under examination. CountingTransport only acknowledges when it is given a pump.
|
||||
var transport = new CountingTransport();
|
||||
|
||||
await using var pump = new TerminalSessionPump(
|
||||
1,
|
||||
shell,
|
||||
transport,
|
||||
TimeProvider.System,
|
||||
new TerminalPumpOptions
|
||||
{
|
||||
FlushInterval = TimeSpan.FromMilliseconds(20),
|
||||
WindowBytes = Window,
|
||||
BacklogBytes = Backlog,
|
||||
});
|
||||
|
||||
var run = pump.RunAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await pump.WriteInputAsync("yes dodossh\n"u8.ToArray(), TestContext.Current.CancellationToken);
|
||||
|
||||
// Long enough that a pump which had stopped at the window would plainly have stopped, and that one
|
||||
// which had not would have read many times the backlog's capacity.
|
||||
var deadline = TimeProvider.System.GetUtcNow() + TimeSpan.FromSeconds(10);
|
||||
|
||||
while (TimeProvider.System.GetUtcNow() < deadline && pump.BytesRead < Backlog * 4L)
|
||||
{
|
||||
await Task.Delay(100, TestContext.Current.CancellationToken);
|
||||
|
||||
pump.BytesPending.ShouldBeLessThanOrEqualTo(
|
||||
Backlog,
|
||||
"output is accumulating on this side of the socket, which is the growth the backlog bounds");
|
||||
}
|
||||
|
||||
pump.BytesRead.ShouldBeGreaterThan(
|
||||
Backlog * 4L,
|
||||
"the pump stopped reading, so the flood is piling up inside SSH.NET where nothing bounds it");
|
||||
|
||||
pump.BytesDropped.ShouldBeGreaterThan(
|
||||
0,
|
||||
"nothing was dropped, so something between here and the screen is still holding all of it");
|
||||
|
||||
await pump.DisposeAsync();
|
||||
|
||||
await Should.NotThrowAsync(() => run.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
/// <summary>Trusts the relay's identity, then connects through it.</summary>
|
||||
/// <remarks>
|
||||
/// Pinned under the relay's own address, which is the honest thing here: what is under examination is a
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace DodoSSH.Client.Ssh.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The probe that runs after a connection has failed, and the four answers it can come back with.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Real sockets on loopback rather than an abstraction over them, because the thing under test <em>is</em>
|
||||
/// what a socket does: the whole reason this type exists is that a refusal and a silence are
|
||||
/// indistinguishable in the exception SSH.NET throws and completely distinguishable to a TCP connect. A fake
|
||||
/// socket would be this suite asserting that the mapping table maps, and would have passed just as happily
|
||||
/// against the bug that prompted the work.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// No container and no sshd. Nothing here speaks SSH — the probe deliberately does not — so the fixture is a
|
||||
/// listener that accepts, a port nothing is on, and a name that does not resolve.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SshReachabilityProbeTests
|
||||
{
|
||||
/// <remarks>
|
||||
/// Short, because two of these cases prove a negative by waiting. The production budget is six seconds —
|
||||
/// see <see cref="SshReachabilityProbe.Budget"/> — and nothing about the branch under test changes with
|
||||
/// the number, so the suite buys the same coverage for a fraction of the wall clock.
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan ShortBudget = TimeSpan.FromSeconds(2);
|
||||
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <remarks>
|
||||
/// The case the whole type was written for. SSH.NET reports this and <see cref="NothingListening"/> with
|
||||
/// the identical "Connection has timed out." — so if this comes back as anything but
|
||||
/// <see cref="SshReach.Open"/>, the probe is adding nothing the exception did not already say.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task APortThatAcceptsIsOpen_WhichIsWhatTellsAWedgedSshdFromABrokenNetwork()
|
||||
{
|
||||
using var listener = Accepting();
|
||||
|
||||
var report = await new SshReachabilityProbe(ShortBudget)
|
||||
.ProbeAsync("127.0.0.1", PortOf(listener), Token);
|
||||
|
||||
report.Reach.ShouldBe(SshReach.Open);
|
||||
report.Address.ShouldBe("127.0.0.1");
|
||||
report.Summary.ShouldContain("handshake");
|
||||
|
||||
// The half a user acts on: nothing here may suggest the network is at fault, because it demonstrably
|
||||
// is not — a socket just opened over it.
|
||||
report.Summary.ShouldNotContain("firewall");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A port with nothing on it. The distinction from <see cref="NothingAnswers"/> is the entire value of
|
||||
/// the finding: refused means the machine is up.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>The full budget, and this test is why the production one is six seconds.</b> A refusal is not
|
||||
/// immediate on Windows — the stack retries the SYN, and a closed loopback port took ~2s to answer when
|
||||
/// this was written. Run against <see cref="ShortBudget"/> this case comes back
|
||||
/// <see cref="SshReach.NoAnswer"/>, which is the probe telling somebody a machine that is up and
|
||||
/// refusing is behind a firewall. Left at the real budget on purpose: it is the one case whose
|
||||
/// correctness depends on the number, so it is the one that should fail if the number is lowered.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task NothingListening()
|
||||
{
|
||||
var report = await new SshReachabilityProbe(SshReachabilityProbe.Budget)
|
||||
.ProbeAsync("127.0.0.1", UnusedPort(), Token);
|
||||
|
||||
report.Reach.ShouldBe(SshReach.Refused);
|
||||
report.Summary.ShouldContain("sshd is not running");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 192.0.2.1 is TEST-NET-1 from RFC 5737: reserved for documentation, routed nowhere, and guaranteed not
|
||||
/// to be some real machine that answers and makes this flap. The packets go out and nothing comes back,
|
||||
/// which is exactly the shape of a firewall that drops rather than rejects.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The elapsed assertion is the one that matters beside the enum. Silence is only evidence if the probe
|
||||
/// actually waited for it, and a bug that returned <see cref="SshReach.NoAnswer"/> instantly — a socket
|
||||
/// erroring for some unrelated reason — would otherwise pass this while telling the user something false
|
||||
/// about their firewall.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task NothingAnswers()
|
||||
{
|
||||
var report = await new SshReachabilityProbe(ShortBudget).ProbeAsync("192.0.2.1", 22, Token);
|
||||
|
||||
report.Reach.ShouldBe(SshReach.NoAnswer);
|
||||
report.Elapsed.ShouldBeGreaterThan(TimeSpan.FromSeconds(1));
|
||||
report.Summary.ShouldContain("firewall");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <c>.invalid</c> is reserved by RFC 2606 precisely so that a test may rely on it never resolving, on
|
||||
/// any network, including one whose resolver answers every NXDOMAIN with a search page.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ANameThatDoesNotResolve()
|
||||
{
|
||||
var report = await new SshReachabilityProbe(ShortBudget)
|
||||
.ProbeAsync("dodossh-no-such-host.invalid", 22, Token);
|
||||
|
||||
report.Reach.ShouldBe(SshReach.Unresolved);
|
||||
report.Address.ShouldBeNull("there is no address, and inventing one would be the whole failure");
|
||||
report.Summary.ShouldContain("does not resolve");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// An address typed in is not looked up, and the report must not read as though it had been. The
|
||||
/// assertion is on the sentence rather than on a call count: what would be wrong is the user being shown
|
||||
/// "127.0.0.1 (127.0.0.1)", which is what a probe that resolved a literal and then printed both produces.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AnAddressIsNotPrintedTwice()
|
||||
{
|
||||
using var listener = Accepting();
|
||||
|
||||
var report = await new SshReachabilityProbe(ShortBudget)
|
||||
.ProbeAsync("127.0.0.1", PortOf(listener), Token);
|
||||
|
||||
report.Summary.ShouldNotContain("(127.0.0.1)");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The caller is in a failure path with nothing to do with a second exception — see
|
||||
/// <see cref="ISshReachabilityProbe.ProbeAsync"/> — so every branch above returns a report. This is the
|
||||
/// one input that is a caller's mistake rather than a broken host, and it is the one that still throws.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AnEmptyHostIsTheCallersBug_NotAFinding()
|
||||
{
|
||||
await Should.ThrowAsync<ArgumentException>(
|
||||
async () => await new SshReachabilityProbe(ShortBudget).ProbeAsync(" ", 22, Token));
|
||||
}
|
||||
|
||||
private static TcpListener Accepting()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
|
||||
return listener;
|
||||
}
|
||||
|
||||
private static int PortOf(TcpListener listener) => ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
|
||||
/// <summary>A port that was free a moment ago, which is the closest thing to a guarantee there is.</summary>
|
||||
/// <remarks>
|
||||
/// Bound and released rather than picked out of the air: the OS hands out a port it knows is unused, and
|
||||
/// the window in which something else could take it is far smaller than the odds of a hard-coded number
|
||||
/// colliding with whatever the machine running this happens to have open.
|
||||
/// </remarks>
|
||||
private static int UnusedPort()
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
|
||||
var port = PortOf(listener);
|
||||
listener.Stop();
|
||||
|
||||
return port;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ internal sealed class FakeShellSession : ISshShellSession
|
||||
private readonly Lock gate = new();
|
||||
|
||||
private readonly bool blockReads;
|
||||
private readonly TimeSpan? pacePerRead;
|
||||
|
||||
private long remaining;
|
||||
private byte pattern;
|
||||
@@ -26,10 +27,26 @@ internal sealed class FakeShellSession : ISshShellSession
|
||||
/// read loop racing the test for control of the pump's credit window — see the reattach tests in
|
||||
/// <c>TerminalWorkspaceTests</c>.
|
||||
/// </param>
|
||||
internal FakeShellSession(long bytesToProduce = 0, bool blockReads = false)
|
||||
/// <param name="pacePerRead">
|
||||
/// How long each read takes, or null for one that returns as fast as the CPU can fill a buffer.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The default producer emits at memory bandwidth, which is faster than any network and faster than
|
||||
/// the pump can frame and send — useful for the throughput harness, and useless for asking whether a
|
||||
/// terminal that <em>can</em> keep up loses anything, because nothing can keep up with it. A pace makes
|
||||
/// the fake a link instead of a memcpy: 8 KiB every millisecond is 8 MB/s, which is a fast remote
|
||||
/// rather than an impossible one.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal FakeShellSession(
|
||||
long bytesToProduce = 0,
|
||||
bool blockReads = false,
|
||||
TimeSpan? pacePerRead = null)
|
||||
{
|
||||
remaining = bytesToProduce;
|
||||
this.blockReads = blockReads;
|
||||
this.pacePerRead = pacePerRead;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -81,6 +98,11 @@ internal sealed class FakeShellSession : ISshShellSession
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (pacePerRead is { } pace)
|
||||
{
|
||||
await Task.Delay(pace, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await Task.Yield();
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
@@ -212,6 +234,40 @@ internal sealed class FakeConnection(SshConnectionRequest request, long bytesPer
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts output without keeping it, and acknowledges it as a renderer that is keeping up would.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="RecordingTransport"/>'s cheap sibling, and the throughput harness needs it to be the one
|
||||
/// measuring rather than the one measured: recording copies every frame into a list that grows to the size
|
||||
/// of the whole run, which at the rates that harness drives makes the <em>test double</em> the slowest thing
|
||||
/// in the pipeline. A harness whose consumer cannot keep up is measuring itself.
|
||||
/// </remarks>
|
||||
internal sealed class CountingTransport : ITerminalTransport
|
||||
{
|
||||
private long outputBytes;
|
||||
|
||||
/// <summary>Set to acknowledge every output frame immediately, as a keeping-up renderer would.</summary>
|
||||
internal TerminalSessionPump? AutoAcknowledge { get; set; }
|
||||
|
||||
/// <summary>Payload bytes of every output frame sent so far.</summary>
|
||||
internal long OutputBytes => Interlocked.Read(ref outputBytes);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask SendAsync(ReadOnlyMemory<byte> frame, CancellationToken cancellationToken)
|
||||
{
|
||||
if (TerminalFrame.TryRead(frame.Span, out var opcode, out _, out var payload)
|
||||
&& opcode == (byte)TerminalServerOpcode.Output)
|
||||
{
|
||||
_ = Interlocked.Add(ref outputBytes, payload.Length);
|
||||
|
||||
AutoAcknowledge?.Acknowledge((uint)payload.Length);
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Records frames, and can acknowledge them to keep credit flowing.</summary>
|
||||
internal sealed class RecordingTransport : ITerminalTransport
|
||||
{
|
||||
@@ -270,6 +326,23 @@ internal sealed class RecordingTransport : ITerminalTransport
|
||||
return [.. output];
|
||||
}
|
||||
|
||||
/// <summary>Concatenated payloads of every frame of one opcode.</summary>
|
||||
internal byte[] PayloadsOf(TerminalServerOpcode opcode)
|
||||
{
|
||||
var payloads = new List<byte>();
|
||||
|
||||
foreach (var frame in Frames)
|
||||
{
|
||||
if (TerminalFrame.TryRead(frame, out var actual, out _, out var payload)
|
||||
&& actual == (byte)opcode)
|
||||
{
|
||||
payloads.AddRange(payload);
|
||||
}
|
||||
}
|
||||
|
||||
return [.. payloads];
|
||||
}
|
||||
|
||||
/// <summary>Counts frames of one opcode.</summary>
|
||||
internal int CountOf(TerminalServerOpcode opcode) =>
|
||||
Frames.Count(frame =>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using DodoSSH.Client.Ssh;
|
||||
|
||||
namespace DodoSSH.Client.Terminal.Tests;
|
||||
@@ -84,13 +85,22 @@ public sealed class TerminalSessionPumpTests
|
||||
await run;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A renderer that has stopped acknowledging does not stop the pump reading, and does not grow it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ◆ THIS TEST REPLACES ONE THAT ASSERTED THE OPPOSITE, and the reversal is the point rather than a
|
||||
/// relaxation. The old contract was that the pump reads exactly one credit window and then stops,
|
||||
/// which was believed to close SSH's receive window and block the remote. It does not — see
|
||||
/// <see cref="OutputBacklog"/> — so all the old behaviour bought was an unbounded buffer inside
|
||||
/// SSH.NET instead of a bounded one here. What has to be true now is both halves at once: reading
|
||||
/// continues, so nothing piles up underneath, <em>and</em> what is held stays inside the backlog.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task WhenCreditRunsOut_ThePumpStopsReading()
|
||||
public async Task WhenCreditRunsOut_ThePumpKeepsReadingAndTheBacklogHoldsTheLine()
|
||||
{
|
||||
// The property the whole design rests on. With no acknowledgement the pump must read exactly
|
||||
// the window and then stop, so nothing accumulates between the socket and the screen. Left
|
||||
// unbounded, a remote running `yes` grows the client's memory until it dies.
|
||||
const int Window = 4 * 1024;
|
||||
const int Backlog = 64 * 1024;
|
||||
|
||||
await using var session = new FakeShellSession(bytesToProduce: long.MaxValue);
|
||||
var transport = new RecordingTransport();
|
||||
@@ -104,19 +114,32 @@ public sealed class TerminalSessionPumpTests
|
||||
ReadBufferBytes = 1024,
|
||||
FlushInterval = TimeSpan.FromMilliseconds(5),
|
||||
WindowBytes = Window,
|
||||
BacklogBytes = Backlog,
|
||||
});
|
||||
|
||||
var run = pump.RunAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(300), TestContext.Current.CancellationToken);
|
||||
|
||||
pump.BytesRead.ShouldBe(
|
||||
Window,
|
||||
"The pump read past its credit window, so output accumulates without bound.");
|
||||
var readByNow = pump.BytesRead;
|
||||
|
||||
// Still stalled a moment later, rather than merely slow.
|
||||
readByNow.ShouldBeGreaterThan(
|
||||
Window,
|
||||
"The pump stopped reading, which leaves the flood accumulating inside SSH.NET instead.");
|
||||
|
||||
pump.BytesPending.ShouldBeLessThanOrEqualTo(
|
||||
Backlog,
|
||||
"The backlog grew past its capacity, which is the unbounded growth it exists to prevent.");
|
||||
|
||||
pump.BytesDropped.ShouldBeGreaterThan(
|
||||
0,
|
||||
"Nothing was dropped, so something is still holding what the renderer never took.");
|
||||
|
||||
// Still reading a moment later, rather than having quietly stalled once the backlog filled.
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
|
||||
pump.BytesRead.ShouldBe(Window);
|
||||
|
||||
pump.BytesRead.ShouldBeGreaterThan(readByNow);
|
||||
pump.BytesPending.ShouldBeLessThanOrEqualTo(Backlog);
|
||||
|
||||
pump.Credits.Available.ShouldBe(0);
|
||||
|
||||
@@ -125,10 +148,13 @@ public sealed class TerminalSessionPumpTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AcknowledgingCredit_LetsThePumpResume()
|
||||
public async Task AcknowledgingCredit_LetsMoreOutputThrough()
|
||||
{
|
||||
const int Window = 4 * 1024;
|
||||
|
||||
// Endless, so the backlog never completes while this is being measured: once it does, the tail
|
||||
// goes out regardless of credit and there is nothing for the window to hold back. Drops along the
|
||||
// way change which bytes are sent, never how many — and the notice they raise is its own opcode.
|
||||
await using var session = new FakeShellSession(bytesToProduce: long.MaxValue);
|
||||
var transport = new RecordingTransport();
|
||||
|
||||
@@ -145,13 +171,16 @@ public sealed class TerminalSessionPumpTests
|
||||
var run = pump.RunAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
|
||||
pump.BytesRead.ShouldBe(Window);
|
||||
|
||||
// One window's worth has gone out and stopped there, because nothing has been rendered.
|
||||
var sentBeforeAcknowledgement = transport.OutputBytes().Length;
|
||||
sentBeforeAcknowledgement.ShouldBe(Window);
|
||||
|
||||
pump.Acknowledge(2048);
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
|
||||
|
||||
pump.BytesRead.ShouldBe(Window + 2048);
|
||||
transport.OutputBytes().Length.ShouldBe(sentBeforeAcknowledgement + 2048);
|
||||
|
||||
await pump.DisposeAsync();
|
||||
await run;
|
||||
@@ -161,9 +190,10 @@ public sealed class TerminalSessionPumpTests
|
||||
public async Task AnOverLargeAcknowledgement_DoesNotWidenTheWindow()
|
||||
{
|
||||
// The acknowledgement comes from JavaScript. A page claiming to have rendered a gigabyte must
|
||||
// not be able to talk the host into an unbounded read.
|
||||
// not be able to talk the host into filling its own buffer without limit.
|
||||
const int Window = 4 * 1024;
|
||||
|
||||
// Endless for the reason the test above gives: a backlog that completes stops the window mattering.
|
||||
await using var session = new FakeShellSession(bytesToProduce: long.MaxValue);
|
||||
var transport = new RecordingTransport();
|
||||
|
||||
@@ -185,7 +215,8 @@ public sealed class TerminalSessionPumpTests
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
|
||||
|
||||
// One further window's worth at most, never everything the endless producer could offer.
|
||||
pump.BytesRead.ShouldBeLessThanOrEqualTo(Window * 2);
|
||||
transport.OutputBytes().Length.ShouldBeLessThanOrEqualTo(Window * 2);
|
||||
pump.Credits.Outstanding.ShouldBeLessThanOrEqualTo(Window);
|
||||
|
||||
await pump.DisposeAsync();
|
||||
await run;
|
||||
@@ -344,7 +375,11 @@ public sealed class TerminalSessionPumpTests
|
||||
const int Window = CreditWindow.DefaultWindowBytes;
|
||||
|
||||
await using var session = new FakeShellSession(bytesToProduce: Target);
|
||||
var transport = new RecordingTransport();
|
||||
|
||||
// Counting rather than recording, for the reason CountingTransport carries: a consumer that
|
||||
// copies every frame into a 32 MB list is the slowest thing in this pipeline, and a harness
|
||||
// whose consumer cannot keep up measures the harness.
|
||||
var transport = new CountingTransport();
|
||||
|
||||
await using var pump = CreatePump(
|
||||
session,
|
||||
@@ -369,7 +404,15 @@ public sealed class TerminalSessionPumpTests
|
||||
var peakOutstanding = await sampler;
|
||||
|
||||
pump.BytesRead.ShouldBe(Target);
|
||||
transport.OutputBytes().Length.ShouldBe((int)Target);
|
||||
|
||||
// ◆ Every byte read either reached the renderer or was counted as dropped, and none of it went
|
||||
// anywhere else. That is the invariant worth asserting here rather than "nothing was dropped":
|
||||
// this producer emits at memory bandwidth with no network under it, which is faster than any
|
||||
// real remote and faster than the pump can frame and send, so drops are the honest outcome and
|
||||
// the harness would be lying to forbid them. What must never happen is a byte that is neither
|
||||
// delivered nor accounted for — see NothingIsDropped_WhenTheRendererKeepsUpWithTheRemote for
|
||||
// the case where the producer is one a network could actually deliver.
|
||||
(transport.OutputBytes + pump.BytesDropped).ShouldBe(Target);
|
||||
|
||||
var megabytesPerSecond = Target / 1024d / 1024d / stopwatch.Elapsed.TotalSeconds;
|
||||
|
||||
@@ -387,6 +430,142 @@ public sealed class TerminalSessionPumpTests
|
||||
$"Peak in-flight was {peakOutstanding} bytes against a {Window}-byte window."));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A renderer that keeps up loses nothing, however much the remote sends.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The other half of the trade the backlog makes, and the half a user actually lives in: dropping is
|
||||
/// for a terminal that cannot keep up, and a terminal that can must show every byte. The producer here
|
||||
/// is sized for a real link rather than for memory bandwidth — several times the backlog, delivered in
|
||||
/// pieces a network could carry — so a drop would mean the pump losing output it had room for.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task NothingIsDropped_WhenTheRendererKeepsUpWithTheRemote()
|
||||
{
|
||||
const int Backlog = 256 * 1024;
|
||||
const long Target = 512L * 1024;
|
||||
|
||||
// 8 KiB a millisecond — 8 MB/s, a fast remote rather than an impossible one, and thirty-two
|
||||
// times the backlog's capacity per second so the pump has no room to be merely lucky.
|
||||
await using var session = new FakeShellSession(
|
||||
bytesToProduce: Target,
|
||||
pacePerRead: TimeSpan.FromMilliseconds(1));
|
||||
|
||||
var transport = new CountingTransport();
|
||||
|
||||
await using var pump = CreatePump(
|
||||
session,
|
||||
transport,
|
||||
new TerminalPumpOptions
|
||||
{
|
||||
ReadBufferBytes = 8 * 1024,
|
||||
FlushInterval = TimeSpan.FromMilliseconds(1),
|
||||
WindowBytes = CreditWindow.DefaultWindowBytes,
|
||||
BacklogBytes = Backlog,
|
||||
});
|
||||
|
||||
transport.AutoAcknowledge = pump;
|
||||
|
||||
await pump.RunAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
pump.BytesDropped.ShouldBe(0);
|
||||
transport.OutputBytes.ShouldBe(Target);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A shell that ends against a renderer which stopped acknowledging still finishes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ◆ THE HANG THIS DESIGN MAKES POSSIBLE, AND THE TEST THAT SAYS IT DOES NOT HAPPEN. The flush loop
|
||||
/// waits on credit when the renderer is behind, and completing the backlog is a different signal from
|
||||
/// returning credit — so a run whose reader finished while the flusher was parked on an
|
||||
/// acknowledgement that will never come would wait for ever. Nothing above would notice: the shell has
|
||||
/// ended, but the tab is told by <c>SessionEnded</c>, which fires off the run task completing, and the
|
||||
/// connection is given back at the same moment. A session in that state is invisible and permanent.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task WhenTheRemoteCloses_TheRunEndsEvenIfTheRendererNeverAcknowledged()
|
||||
{
|
||||
const int Window = 4 * 1024;
|
||||
|
||||
// More than the window, so the flusher has certainly run out of credit and parked by the time the
|
||||
// producer reaches the end of what it has to give.
|
||||
await using var session = new FakeShellSession(bytesToProduce: Window * 4);
|
||||
|
||||
// No AutoAcknowledge, ever: this renderer's process died holding the socket.
|
||||
var transport = new RecordingTransport();
|
||||
|
||||
await using var pump = CreatePump(
|
||||
session,
|
||||
transport,
|
||||
new TerminalPumpOptions
|
||||
{
|
||||
ReadBufferBytes = 1024,
|
||||
FlushInterval = TimeSpan.FromMilliseconds(5),
|
||||
WindowBytes = Window,
|
||||
});
|
||||
|
||||
var run = pump.RunAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await Should.NotThrowAsync(() => run.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken));
|
||||
|
||||
pump.Outcome.End.ShouldBe(SshSessionEnd.RemoteClosed);
|
||||
|
||||
// And the tail went out rather than being abandoned with the flusher: the last thing a remote
|
||||
// writes is usually why it ended.
|
||||
transport.OutputBytes().Length.ShouldBe(Window * 4);
|
||||
transport.CountOf(TerminalServerOpcode.SessionClosed).ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A drop is announced in the pane rather than leaving a silent gap.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The thing that makes dropping defensible at all. A terminal quietly showing a different transcript
|
||||
/// from the one the remote sent is worse than one that says it could not keep up, because the second
|
||||
/// can be believed. Its own opcode, so the sentence is never counted as something the remote said —
|
||||
/// see <see cref="TerminalServerOpcode.OutputDropped"/>.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task WhenOutputIsDropped_TheUserIsToldInThePane()
|
||||
{
|
||||
const int Window = 4 * 1024;
|
||||
const int Backlog = 16 * 1024;
|
||||
|
||||
await using var session = new FakeShellSession(bytesToProduce: long.MaxValue);
|
||||
var transport = new RecordingTransport();
|
||||
|
||||
await using var pump = CreatePump(
|
||||
session,
|
||||
transport,
|
||||
new TerminalPumpOptions
|
||||
{
|
||||
ReadBufferBytes = 1024,
|
||||
FlushInterval = TimeSpan.FromMilliseconds(5),
|
||||
WindowBytes = Window,
|
||||
BacklogBytes = Backlog,
|
||||
});
|
||||
|
||||
var run = pump.RunAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
|
||||
|
||||
// Enough credit for one more frame, which is what carries the notice out.
|
||||
pump.Acknowledge((uint)Window);
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
|
||||
|
||||
transport.CountOf(TerminalServerOpcode.OutputDropped).ShouldBeGreaterThan(0);
|
||||
|
||||
var notice = Encoding.UTF8.GetString(transport.PayloadsOf(TerminalServerOpcode.OutputDropped));
|
||||
|
||||
notice.ShouldContain("of output was dropped");
|
||||
notice.ShouldStartWith("\u001b[0m");
|
||||
|
||||
await pump.DisposeAsync();
|
||||
await run;
|
||||
}
|
||||
|
||||
/// <summary>Watches in-flight bytes for the duration of a run, returning the highest seen.</summary>
|
||||
private static async Task<int> SamplePeakOutstandingAsync(
|
||||
TerminalSessionPump pump,
|
||||
|
||||
@@ -27,6 +27,12 @@ public sealed class TerminalWorkspaceTests
|
||||
/// <summary>How long <see cref="WaitUntilAsync"/> polls before calling it a failure.</summary>
|
||||
private static readonly TimeSpan PollTimeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
/// <summary>
|
||||
/// How long <see cref="ConnectRendererAsync"/> waits for the attach's replay before calling it a
|
||||
/// failure. Generous, because it bounds a hang rather than timing anything.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan ReplayTimeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
[Fact]
|
||||
public async Task WaitingForARendererThatNeverAttaches_GivesUp()
|
||||
{
|
||||
@@ -83,9 +89,9 @@ public sealed class TerminalWorkspaceTests
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The case a naive <c>sessions.Count</c> gets wrong. Nothing removes the entry when the remote
|
||||
/// closes the channel by itself — the session is still in the dictionary, holding a connection that
|
||||
/// is finished — so counting entries would report a shell that exited as still running. A user
|
||||
/// The case a naive <c>sessions.Count</c> gets wrong. Nothing removes the entry when the remote closes
|
||||
/// the channel by itself — what it held is given back, but the entry stays so the tab can go on saying
|
||||
/// the session ended — so counting entries would report a shell that exited as still running. A user
|
||||
/// deciding whether it is safe to walk away is the person that lie is told to.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
@@ -645,6 +651,86 @@ public sealed class TerminalWorkspaceTests
|
||||
await workspace.SendInputAsync(sessionId, "x"u8.ToArray(), TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A shell that ended on its own gives its connection back at once, without waiting for the tab.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ◆ THE HALF THAT WAS MISSING. Closing a tab disposed the connection and always had; a remote hanging
|
||||
/// up did not, so a session that ended went on holding an authenticated socket, a keep-alive putting a
|
||||
/// packet on the wire every thirty seconds, and SSH.NET's read buffer at whatever size the session's
|
||||
/// busiest moment had grown it to — for as long as the user left the tab open, which for a tab showing
|
||||
/// the last thing a job said is indefinitely.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ASessionWhoseRemoteHasExited_GivesBackItsConnectionWithoutWaitingForTheTab()
|
||||
{
|
||||
// A shell with no output to give: its first read returns 0, which is the remote closing the
|
||||
// channel, so the pump finishes with nobody having asked it to.
|
||||
var connections = new FakeConnectionFactory(bytesPerShell: 0);
|
||||
|
||||
await using var workspace = CreateWorkspace(connections);
|
||||
|
||||
var sessionId = await workspace.OpenSessionAsync(
|
||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||
|
||||
await WaitUntilAsync(() => connections.Connections.TrueForAll(connection => connection.IsDisposed));
|
||||
|
||||
var connection = connections.Connections.ShouldHaveSingleItem();
|
||||
|
||||
connection.IsConnected.ShouldBeFalse();
|
||||
connection.Shell.ShouldNotBeNull().IsOpen.ShouldBeFalse();
|
||||
|
||||
// The entry itself stays, and has to: the tab is still on screen, still says it ended, and still
|
||||
// offers a reconnect — all of which read this workspace rather than the tab strip's own memory.
|
||||
workspace.CreditsFor(sessionId).ShouldNotBeNull();
|
||||
workspace.IsSessionLive(sessionId).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The consequence of the release above, and the reason <c>SendInputAsync</c> asks about liveness
|
||||
/// rather than presence: the shell channel is disposed the moment the remote hangs up, so an accessory
|
||||
/// key pressed at a tab that ended reaches a disposed stream. It has to be as ordinary as pressing one
|
||||
/// at a tab that has closed, which is to say nothing happens.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task SendingInputToASessionWhoseRemoteHasExited_IsIgnored()
|
||||
{
|
||||
var connections = new FakeConnectionFactory(bytesPerShell: 0);
|
||||
|
||||
await using var workspace = CreateWorkspace(connections);
|
||||
|
||||
var sessionId = await workspace.OpenSessionAsync(
|
||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||
|
||||
await WaitUntilAsync(() => !workspace.IsSessionLive(sessionId));
|
||||
|
||||
await Should.NotThrowAsync(async () =>
|
||||
await workspace.SendInputAsync(sessionId, "x"u8.ToArray(), TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Closing a tab over a session that already released itself must still work, because that is the
|
||||
/// ordinary way a dead tab goes away. Everything the close does is idempotent by construction — the
|
||||
/// entry is removed under the lock, and both disposals are guarded — and this is the test that says so
|
||||
/// rather than leaving it to be inferred from three separate classes.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ClosingATabOverASessionThatAlreadyEnded_IsStillSafe()
|
||||
{
|
||||
var connections = new FakeConnectionFactory(bytesPerShell: 0);
|
||||
|
||||
await using var workspace = CreateWorkspace(connections);
|
||||
|
||||
var sessionId = await workspace.OpenSessionAsync(
|
||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
||||
|
||||
await WaitUntilAsync(() => !workspace.IsSessionLive(sessionId));
|
||||
|
||||
await Should.NotThrowAsync(() => workspace.CloseSessionAsync(sessionId));
|
||||
|
||||
workspace.CreditsFor(sessionId).ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <param name="connections">
|
||||
/// How connections are made. The renderer-gate tests never reach it — they stop at the gate — but they
|
||||
/// take a fake anyway, because reaching a real host from here would make this a network test.
|
||||
@@ -693,11 +779,20 @@ public sealed class TerminalWorkspaceTests
|
||||
/// </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.
|
||||
/// returns immediately without proving anything about the second socket — and even for the first it
|
||||
/// answers too early for what the callers here need. It completes when the socket is attached, which
|
||||
/// is before <see cref="TerminalWorkspace.ReplayAfterAttachAsync"/> has run: that is fired and
|
||||
/// forgotten from the accept thread, and a thread-pool that deschedules it for a timeslice lets the
|
||||
/// test open a session in the meantime. The replay then finds that session live and sends it a second,
|
||||
/// replay-flagged <c>SessionOpened</c> — harmless to the page, which treats it as idempotent, but it
|
||||
/// lands in the middle of the frames the test is counting on.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ SO WAIT FOR THE REPLAY TO BE OVER, NOT ONLY FOR THE ATTACH. <c>RendererReattached</c> is raised
|
||||
/// as the last thing the replay does, so a socket this returns has had every frame the attach itself
|
||||
/// causes, and nothing but what the test does next can put another on it. Subscribed before the
|
||||
/// upgrade, because the replay for a socket that attaches quickly can be over before this method gets
|
||||
/// to look.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static async Task<ClientWebSocket> ConnectRendererAsync(TerminalWorkspace workspace)
|
||||
@@ -715,17 +810,29 @@ public sealed class TerminalWorkspaceTests
|
||||
"Origin",
|
||||
string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{workspace.PageUrl.Port}"));
|
||||
|
||||
var replayed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
void OnReattached(object? sender, EventArgs e) => replayed.TrySetResult();
|
||||
|
||||
workspace.RendererReattached += OnReattached;
|
||||
|
||||
try
|
||||
{
|
||||
await client.ConnectAsync(new Uri(socketUrl), TestContext.Current.CancellationToken);
|
||||
|
||||
await workspace.WaitForRendererAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
await replayed.Task.WaitAsync(ReplayTimeout, TestContext.Current.CancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
client.Dispose();
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
workspace.RendererReattached -= OnReattached;
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user