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:
|
build:
|
||||||
name: build and test
|
name: build and test
|
||||||
runs-on: [linux]
|
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:
|
steps:
|
||||||
# act_runner runs every `uses:` action with node inside the job container, and this
|
# 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
|
# runner's image has none — the run died on the first line of actions/checkout with
|
||||||
@@ -220,7 +232,12 @@ jobs:
|
|||||||
- name: build
|
- name: build
|
||||||
run: dotnet build DodoSSH.slnx --no-restore --configuration Release
|
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
|
- name: test
|
||||||
|
timeout-minutes: 10
|
||||||
run: dotnet test DodoSSH.slnx --no-build --configuration Release
|
run: dotnet test DodoSSH.slnx --no-build --configuration Release
|
||||||
|
|
||||||
# The one build shape nothing else here exercises: a self-contained RID-specific publish. Its
|
# The one build shape nothing else here exercises: a self-contained RID-specific publish. Its
|
||||||
@@ -556,6 +573,8 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
android:
|
android:
|
||||||
name: android head
|
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]
|
runs-on: [linux]
|
||||||
# Writes the nightly release at the end of the job; see that step for what the capability is and why
|
# 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.
|
# it is acceptable here and nowhere else. Job-scoped, so nothing else in this file gains it.
|
||||||
@@ -915,7 +934,10 @@ jobs:
|
|||||||
# never made.
|
# never made.
|
||||||
published="$(curl -fsS -H "$auth" "$api/releases/$release")"
|
published="$(curl -fsS -H "$auth" "$api/releases/$release")"
|
||||||
for name in $names; do
|
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
|
echo "The release was created but $name is not on it:" >&2
|
||||||
printf '%s\n' "$published" >&2
|
printf '%s\n' "$published" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -926,6 +948,8 @@ jobs:
|
|||||||
|
|
||||||
desktop:
|
desktop:
|
||||||
name: desktop nightly
|
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
|
# 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
|
# installed by people and replaces itself afterwards. Nothing anybody runs should come out of a
|
||||||
# commit whose suite was red.
|
# commit whose suite was red.
|
||||||
@@ -1226,7 +1250,10 @@ jobs:
|
|||||||
published="$(curl -fsS -H "$auth" "$api/releases/tags/$TAG")"
|
published="$(curl -fsS -H "$auth" "$api/releases/tags/$TAG")"
|
||||||
|
|
||||||
for name in releases.win-nightly.json DodoSSH.Desktop.Nightly-win-nightly-Setup.exe; do
|
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
|
echo "The release was created but $name is not on it:" >&2
|
||||||
printf '%s\n' "$published" >&2
|
printf '%s\n' "$published" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -1279,7 +1306,8 @@ jobs:
|
|||||||
# upload it never made.
|
# upload it never made.
|
||||||
withRpm="$(curl -fsS -H "$auth" "$api/releases/$release")"
|
withRpm="$(curl -fsS -H "$auth" "$api/releases/$release")"
|
||||||
for name in $linuxNames; do
|
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
|
echo "The release was created but $name is not on it:" >&2
|
||||||
printf '%s\n' "$withRpm" >&2
|
printf '%s\n' "$withRpm" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -1290,6 +1318,8 @@ jobs:
|
|||||||
|
|
||||||
image:
|
image:
|
||||||
name: api 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
|
# 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
|
# 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
|
# registry from a commit whose tests were red. An image is not a build artefact anyone
|
||||||
|
|||||||
+8
-1
@@ -87,9 +87,16 @@ Releases/
|
|||||||
*.pfx
|
*.pfx
|
||||||
*.p12
|
*.p12
|
||||||
*.snk
|
*.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
|
||||||
appsettings.*.Local.json
|
appsettings.*.Local.json
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!deploy/.env.example
|
|
||||||
secrets/
|
secrets/
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
# Development dependencies only: PostgreSQL and Keycloak.
|
# Development dependencies only: PostgreSQL and Keycloak.
|
||||||
#
|
#
|
||||||
# The API itself runs from the IDE or `dotnet run`, so the inner loop stays fast while the
|
# 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
|
# docker compose -f deploy/docker-compose.dev.yml up -d
|
||||||
# dotnet run --project src/DodoSSH.Api
|
# 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
|
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.
|
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
|
### 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
|
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
|
`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.
|
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
|
## 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
|
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.
|
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
|
## 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
|
# 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.
|
# 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")"
|
requires="$(rpm -qp --requires "$rpm_file")"
|
||||||
provides="$(rpm -qp --provides "$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)"
|
carried="$(printf '%s\n' "$files" | sed -n 's|^/opt/.*/\(lib[^/]*\.so[^/]*\)$|\1|p' | sort -u)"
|
||||||
unsatisfiable=""
|
unsatisfiable=""
|
||||||
for soname in $carried; do
|
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"
|
unsatisfiable="$unsatisfiable $soname"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
@@ -251,7 +270,7 @@ fi
|
|||||||
# 2. The provides exclusion still doing its job. A package advertising libcoreclr.so to the whole
|
# 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
|
# 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.
|
# 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
|
echo "This package advertises bundled libraries to the system:" >&2
|
||||||
printf '%s\n' "$provides" | grep -E '^lib.*\.so' >&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
|
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
|
# 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
|
# 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.
|
# 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 "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
|
echo "The automatic ELF scan produced nothing; rpm here is probably built without elfdeps." >&2
|
||||||
printf '%s\n' "$requires" >&2
|
printf '%s\n' "$requires" >&2
|
||||||
@@ -279,9 +298,9 @@ for expected in \
|
|||||||
"/usr/share/icons/hicolor/256x256/apps/$bin.png" \
|
"/usr/share/icons/hicolor/256x256/apps/$bin.png" \
|
||||||
"/usr/lib/udev/rules.d/71-dodossh-tpm.rules"
|
"/usr/lib/udev/rules.d/71-dodossh-tpm.rules"
|
||||||
do
|
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
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
done
|
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 }
|
if (Test-Path $Staging) { Remove-Item -Recurse -Force $Staging }
|
||||||
New-Item -ItemType Directory -Path $Staging | Out-Null
|
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
|
# ◆ THE PASSWORDS GO THROUGH THE ENVIRONMENT, BECAUSE -p: PUTS THEM ON A COMMAND LINE.
|
||||||
# 32-bit handset or an x86_64 emulator, and this is the package strangers are pointed at.
|
#
|
||||||
& dotnet build $Project --no-restore --configuration Release `
|
# They used to be written straight into -p:AndroidSigningStorePass=..., which reads as private and is
|
||||||
-t:SignAndroidPackage `
|
# not. A process's command line is not a secret on Windows: any process running as this user can read
|
||||||
-p:DodoChannel=release `
|
# every argument of every other with one call —
|
||||||
-p:AndroidKeyStore=true `
|
#
|
||||||
-p:AndroidSigningKeyStore=$KeystorePath `
|
# Get-CimInstance Win32_Process | Select-Object Name, CommandLine
|
||||||
-p:AndroidSigningKeyAlias=$KeyAlias `
|
#
|
||||||
-p:AndroidSigningStorePass=$storePlain `
|
# — and process-creation auditing, EDR agents and crash dumps all record it as a matter of course. The
|
||||||
-p:AndroidSigningKeyPass=$keyPlain
|
# 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.' }
|
if ($LASTEXITCODE -ne 0) { Stop-With 'The build failed.' }
|
||||||
|
|
||||||
|
|||||||
@@ -38,9 +38,11 @@
|
|||||||
# xcrun notarytool store-credentials DodoSSH \
|
# xcrun notarytool store-credentials DodoSSH \
|
||||||
# --apple-id you@example.com --team-id TEAMID --password <app-specific-password>
|
# --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
|
# The forge token is the one real secret, and it is prompted for rather than read from a file or an
|
||||||
# environment, and only in the phase that needs it — for the reason the Windows script gives: the fewer
|
# ambient environment variable, and only in the phase that needs it — for the reason the Windows script
|
||||||
# minutes a credential that can publish an update spends in a shell's memory the better.
|
# 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:
|
# Usage:
|
||||||
# bash scripts/release-macos.sh
|
# 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
|
# 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
|
# 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.
|
# 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_ARGS=(
|
||||||
upload gitea
|
upload gitea
|
||||||
--repoUrl "$REPO_URL"
|
--repoUrl "$REPO_URL"
|
||||||
--token "$TOKEN"
|
|
||||||
--outputDir "$RELEASES_DIR"
|
--outputDir "$RELEASES_DIR"
|
||||||
--channel "$CHANNEL"
|
--channel "$CHANNEL"
|
||||||
--releaseName "$TAG"
|
--releaseName "$TAG"
|
||||||
@@ -214,7 +224,15 @@ if [ "$UPLOAD" -eq 1 ]; then
|
|||||||
esac
|
esac
|
||||||
|
|
||||||
write_step 'Uploading'
|
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."
|
write_step "Published $TAG."
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -16,9 +16,11 @@
|
|||||||
2. With -Upload: asks for the forge token and publishes what phase 1 produced. It does not rebuild,
|
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.
|
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
|
The token is prompted for rather than read from a file or an ambient environment variable, and only in
|
||||||
that needs it — the build does not, and the fewer minutes a credential that can publish an update spends
|
the phase that needs it — the build does not, and the fewer minutes a credential that can publish an
|
||||||
in a shell's memory the better.
|
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
|
.PARAMETER Upload
|
||||||
Publish the packages already in Releases/ instead of building.
|
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
|
# --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
|
# 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.
|
# 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 = @(
|
$uploadArgs = @(
|
||||||
'upload', 'gitea',
|
'upload', 'gitea',
|
||||||
'--repoUrl', $RepoUrl,
|
'--repoUrl', $RepoUrl,
|
||||||
'--token', $token,
|
|
||||||
'--outputDir', $ReleasesDir,
|
'--outputDir', $ReleasesDir,
|
||||||
'--channel', $Channel,
|
'--channel', $Channel,
|
||||||
'--releaseName', $tag,
|
'--releaseName', $tag,
|
||||||
@@ -169,7 +185,17 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Write-Step 'Uploading'
|
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.' }
|
if ($LASTEXITCODE -ne 0) { Stop-With 'vpk upload failed.' }
|
||||||
|
|
||||||
Write-Step "Published $tag."
|
Write-Step "Published $tag."
|
||||||
|
|||||||
@@ -115,10 +115,20 @@ public sealed class RelayOptions
|
|||||||
/// Whether RFC1918 and other private ranges may be dialled.
|
/// Whether RFC1918 and other private ranges may be dialled.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// Defaults to true, unlike a typical SSRF allow-list, because reaching private
|
/// 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
|
/// 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,
|
/// the ACL: only hosts in a vault the caller holds Connect on can be dialled at all.
|
||||||
/// link-local and cloud metadata ranges are denied unconditionally and are not configurable.
|
/// </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>
|
/// </remarks>
|
||||||
public bool AllowPrivateNetworks { get; set; } = true;
|
public bool AllowPrivateNetworks { get; set; } = true;
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Avalonia.Controls;
|
|||||||
using Avalonia.Controls.ApplicationLifetimes;
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
using Avalonia.Input.Platform;
|
using Avalonia.Input.Platform;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Avalonia.Threading;
|
||||||
using DodoSSH.Client.App.Platform;
|
using DodoSSH.Client.App.Platform;
|
||||||
using DodoSSH.Client.App.Views;
|
using DodoSSH.Client.App.Views;
|
||||||
using DodoSSH.Client.Auth;
|
using DodoSSH.Client.Auth;
|
||||||
@@ -33,12 +34,77 @@ internal sealed partial class DodoSshApp : Application
|
|||||||
{
|
{
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
{
|
{
|
||||||
Compose(desktop);
|
ShowSomethingWhileComposing(desktop);
|
||||||
}
|
}
|
||||||
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
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>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Composed by hand rather than through a container. The graph is a handful of objects deep and an
|
/// 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 };
|
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
|
// 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
|
// 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.
|
// discarding the task here is safe rather than merely convenient.
|
||||||
_ = viewModel.StartAsync(CancellationToken.None);
|
_ = 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>
|
/// <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,
|
IClassicDesktopStyleApplicationLifetime desktop,
|
||||||
MainWindowViewModel viewModel,
|
MainWindowViewModel viewModel,
|
||||||
TerminalWorkspace workspace,
|
TerminalWorkspace workspace,
|
||||||
ClientCacheFactory caches)
|
ClientCacheFactory caches,
|
||||||
|
IAsyncDisposable others)
|
||||||
{
|
{
|
||||||
var shuttingDown = false;
|
var shuttingDown = false;
|
||||||
|
|
||||||
@@ -290,6 +389,11 @@ internal sealed partial class DodoSshApp : Application
|
|||||||
await viewModel.DisposeAsync().ConfigureAwait(true);
|
await viewModel.DisposeAsync().ConfigureAwait(true);
|
||||||
await workspace.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();
|
caches.Dispose();
|
||||||
|
|
||||||
desktop.Shutdown();
|
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();
|
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);
|
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -111,13 +111,33 @@
|
|||||||
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="18" MaxWidth="460"
|
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="18" MaxWidth="460"
|
||||||
Margin="24">
|
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">
|
<StackPanel Spacing="6" HorizontalAlignment="Center">
|
||||||
<TextBlock Classes="mono" Text="{Binding SelectedTab.Label}" FontSize="16" FontWeight="Medium"
|
<SelectableTextBlock Classes="mono" Text="{Binding SelectedTab.Label}" FontSize="16"
|
||||||
Foreground="{StaticResource Text}" HorizontalAlignment="Center"
|
FontWeight="Medium" Foreground="{StaticResource Text}"
|
||||||
TextTrimming="CharacterEllipsis" />
|
HorizontalAlignment="Center" TextTrimming="CharacterEllipsis"
|
||||||
<TextBlock Classes="mono" Text="{Binding SelectedTab.Address}" FontSize="11.5"
|
ToolTip.Tip="{Binding SelectedTab.Label}" />
|
||||||
Foreground="{StaticResource TextFaint}" HorizontalAlignment="Center"
|
<SelectableTextBlock Classes="mono" Text="{Binding SelectedTab.Address}" FontSize="11.5"
|
||||||
TextTrimming="CharacterEllipsis" />
|
Foreground="{StaticResource TextFaint}" HorizontalAlignment="Center"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
ToolTip.Tip="{Binding SelectedTab.Address}" />
|
||||||
</StackPanel>
|
</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>
|
/// </remarks>
|
||||||
public string DeviceKeyFile => Path.Combine(DataDirectory, "device.key");
|
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>
|
/// <summary>Creates the profile directory if it is not there yet.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Separate from resolving the path, because resolving must never have a side effect: it is read
|
/// 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>
|
/// </remarks>
|
||||||
private readonly Func<Task<string?>>? readClipboard;
|
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']" />
|
/// <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;
|
private readonly Action<Action> post;
|
||||||
|
|
||||||
@@ -556,8 +568,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
IUpdateChannel? updates = null,
|
IUpdateChannel? updates = null,
|
||||||
Action<Action>? post = null,
|
Action<Action>? post = null,
|
||||||
Func<string, Task>? openInEditor = 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.post = post ?? (action => Dispatcher.UIThread.Post(action));
|
||||||
|
|
||||||
this.paths = paths;
|
this.paths = paths;
|
||||||
@@ -2871,7 +2885,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
ReconnectAsync,
|
ReconnectAsync,
|
||||||
copyToClipboard,
|
copyToClipboard,
|
||||||
connectionLog,
|
connectionLog,
|
||||||
visibility);
|
visibility,
|
||||||
|
reachability);
|
||||||
State = ShellState.Unlocked;
|
State = ShellState.Unlocked;
|
||||||
|
|
||||||
// Offered only where it can actually be honoured: a machine that can keep a key, and a profile that
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var tab = new TerminalTabViewModel(e.Label, e.Address);
|
var tab = new TerminalTabViewModel(e.Label, e.Address) { HostId = e.HostId };
|
||||||
attempts[e.AttemptId] = tab;
|
attempts[e.AttemptId] = tab;
|
||||||
|
|
||||||
AdoptTab(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.
|
// dropping it would leave a shell running with nothing in the window naming it.
|
||||||
var adopted = new TerminalTabViewModel(e.SessionId, e.Label, e.Address)
|
var adopted = new TerminalTabViewModel(e.SessionId, e.Label, e.Address)
|
||||||
{
|
{
|
||||||
|
HostId = e.HostId,
|
||||||
StartedAt = clock.GetUtcNow(),
|
StartedAt = clock.GetUtcNow(),
|
||||||
Cipher = NullIfEmpty(e.Cipher),
|
Cipher = NullIfEmpty(e.Cipher),
|
||||||
HostKeyAlgorithm = NullIfEmpty(e.HostKeyAlgorithm),
|
HostKeyAlgorithm = NullIfEmpty(e.HostKeyAlgorithm),
|
||||||
@@ -4021,13 +4037,22 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Matched on the label, which is the same handle <see cref="RefreshConnectedHosts"/> matches on and for
|
/// ◆ <b>Matched on the keychain item's id, never on the name.</b> Nothing stops two hosts being called
|
||||||
/// the same reason: a tab outlives the vault that opened it, so it cannot hold an entity id that would
|
/// the same thing — a team's vault beside a personal one makes it ordinary, and "db" is what people call
|
||||||
/// still mean anything after a lock.
|
/// 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>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Null is the ordinary answer for two real cases, and both of them are why the reconnect below has to be
|
/// 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>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private HostRowViewModel? HostFor(TerminalTabViewModel? tab) =>
|
private HostRowViewModel? HostFor(TerminalTabViewModel? tab) =>
|
||||||
tab is null || Vault is not { } vault
|
tab?.HostId is not { } hostId || Vault is not { } vault
|
||||||
? null
|
? null
|
||||||
: vault.Hosts.FirstOrDefault(
|
: vault.Hosts.FirstOrDefault(row => row.EntityId == hostId);
|
||||||
row => string.Equals(row.Label, tab.Label, StringComparison.Ordinal));
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether the selected tab has stopped and there is something to dial again.
|
/// 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>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Matched on the label, which is what a tab was named after, because that is the only handle the two
|
/// Matched on the keychain item's id, the same handle <see cref="HostFor"/> matches on and for the
|
||||||
/// lists share — a tab outlives the vault that opened it, so it cannot hold an entity id that would
|
/// reason given there: two hosts may share a name, and this list is read as "which machines am I on".
|
||||||
/// still mean anything after a lock. Two hosts sharing a name would light both dots, which is a smaller
|
/// Lighting both dots for a name two machines answer to would say the user has a shell open on a host
|
||||||
/// wrong than a dot that goes dark when the vault is reopened.
|
/// 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>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <see cref="ActiveTabPinnedPaths"/> is rebuilt here, on the same match, rather than from a subscription
|
/// <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)
|
foreach (var host in vault.Hosts)
|
||||||
{
|
{
|
||||||
host.IsConnected = Tabs.Any(
|
host.IsConnected = Tabs.Any(tab => tab.IsLive && tab.HostId == host.EntityId);
|
||||||
tab => tab.IsLive && string.Equals(tab.Label, host.Label, StringComparison.Ordinal));
|
|
||||||
|
|
||||||
// The active tab's host, and only when it is actually connected: the sidebar is for a session
|
// 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.
|
// that is open, not for whichever machine's tab happens to be selected while it is still dialling.
|
||||||
if (host.IsConnected
|
if (host.IsConnected && SelectedTab is { } selected && selected.HostId == host.EntityId)
|
||||||
&& SelectedTab is { } selected
|
|
||||||
&& string.Equals(host.Label, selected.Label, StringComparison.Ordinal))
|
|
||||||
{
|
{
|
||||||
connectedActiveHost = host;
|
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
|
/// 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
|
/// <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
|
/// 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>
|
/// </remarks>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task OpenPinnedPathAsync(string path)
|
private async Task OpenPinnedPathAsync(string path)
|
||||||
{
|
{
|
||||||
if (Vault is not { } vault
|
if (HostFor(SelectedTab) is not { } host)
|
||||||
|| SelectedTab is not { } tab
|
|
||||||
|| vault.Hosts.FirstOrDefault(
|
|
||||||
host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
|
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -4623,10 +4648,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void PinFolderFromSidebar()
|
private void PinFolderFromSidebar()
|
||||||
{
|
{
|
||||||
if (Vault is not { } vault
|
if (Vault is not { } vault || HostFor(SelectedTab) is not { } host)
|
||||||
|| SelectedTab is not { } tab
|
|
||||||
|| vault.Hosts.FirstOrDefault(
|
|
||||||
host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
|
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -4659,10 +4681,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task SelectFilesHostAsync(TerminalTabViewModel tab)
|
private async Task SelectFilesHostAsync(TerminalTabViewModel tab)
|
||||||
{
|
{
|
||||||
if (Vault is not { } vault
|
if (HostFor(tab) is not { } host)
|
||||||
|| tab is null
|
|
||||||
|| vault.Hosts.FirstOrDefault(
|
|
||||||
host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
|
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,6 +229,31 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
|||||||
|
|
||||||
internal string Label { get; }
|
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>
|
/// <summary>The account and endpoint, for the pane header and the status bar.</summary>
|
||||||
internal string Address { get; }
|
internal string Address { get; }
|
||||||
|
|
||||||
|
|||||||
@@ -947,6 +947,7 @@ internal static class ItemBadge
|
|||||||
/// <param name="identityLabel">
|
/// <param name="identityLabel">
|
||||||
/// The display name of the key or credential that authenticated, or null when a typed password did.
|
/// The display name of the key or credential that authenticated, or null when a typed password did.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="hostId">The keychain item this was dialled from, or null for a typed-in address.</param>
|
||||||
internal sealed class TerminalSessionEventArgs(
|
internal sealed class TerminalSessionEventArgs(
|
||||||
Guid attemptId,
|
Guid attemptId,
|
||||||
uint sessionId,
|
uint sessionId,
|
||||||
@@ -954,7 +955,8 @@ internal sealed class TerminalSessionEventArgs(
|
|||||||
string address,
|
string address,
|
||||||
string cipher,
|
string cipher,
|
||||||
string hostKeyAlgorithm,
|
string hostKeyAlgorithm,
|
||||||
string? identityLabel) : EventArgs
|
string? identityLabel,
|
||||||
|
Guid? hostId) : EventArgs
|
||||||
{
|
{
|
||||||
/// <summary>Which attempt this session came out of.</summary>
|
/// <summary>Which attempt this session came out of.</summary>
|
||||||
/// <inheritdoc cref="ConnectionAttemptEventArgs.AttemptId" path="/remarks" />
|
/// <inheritdoc cref="ConnectionAttemptEventArgs.AttemptId" path="/remarks" />
|
||||||
@@ -971,19 +973,27 @@ internal sealed class TerminalSessionEventArgs(
|
|||||||
internal string HostKeyAlgorithm { get; } = hostKeyAlgorithm;
|
internal string HostKeyAlgorithm { get; } = hostKeyAlgorithm;
|
||||||
|
|
||||||
internal string? IdentityLabel { get; } = identityLabel;
|
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>
|
/// <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="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="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="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>
|
/// <remarks>
|
||||||
/// The vault says a connection has started before it says whether it worked, so that the shell can put a
|
/// 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
|
/// 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
|
/// 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.
|
/// has no vault to read it from.
|
||||||
/// </remarks>
|
/// </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>
|
/// <summary>Identifies this attempt for the whole of its life.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -995,6 +1005,19 @@ internal sealed class ConnectionAttemptEventArgs(Guid attemptId, string label, s
|
|||||||
internal string Label { get; } = label;
|
internal string Label { get; } = label;
|
||||||
|
|
||||||
internal string Address { get; } = address;
|
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>
|
/// <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,
|
ServerReconnectHandler? reconnect = null,
|
||||||
Func<string, Task>? copyToClipboard = null,
|
Func<string, Task>? copyToClipboard = null,
|
||||||
ConnectionRecorder? connectionLog = 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>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// A minute. The pull is a delta keyed on a cursor, so an idle pass is one small request and costs the
|
/// 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>
|
/// </remarks>
|
||||||
private sealed class PhaseReporter(Action<SshConnectionPhase> report) : IProgress<SshConnectionPhase>
|
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>
|
/// <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(
|
var attempt = new ConnectionAttemptEventArgs(
|
||||||
Guid.CreateVersion7(),
|
Guid.CreateVersion7(),
|
||||||
target.Label,
|
target.Label,
|
||||||
Dialled(target, authentication));
|
Dialled(target, authentication),
|
||||||
|
target.HostId);
|
||||||
|
|
||||||
ConnectionStarting?.Invoke(this, attempt);
|
ConnectionStarting?.Invoke(this, attempt);
|
||||||
Status = $"Connecting to {target.Label}…";
|
Status = $"Connecting to {target.Label}…";
|
||||||
@@ -11506,9 +11585,13 @@ internal sealed partial class VaultViewModel(
|
|||||||
HostAuthentication authentication,
|
HostAuthentication authentication,
|
||||||
CancellationToken cancellationToken)
|
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
|
try
|
||||||
{
|
{
|
||||||
await ConnectAndAnnounceAsync(attempt, target, authentication, cancellationToken)
|
await ConnectAndAnnounceAsync(attempt, target, authentication, reporter, cancellationToken)
|
||||||
.ConfigureAwait(true);
|
.ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
catch (TimeoutException)
|
catch (TimeoutException)
|
||||||
@@ -11534,10 +11617,87 @@ internal sealed partial class VaultViewModel(
|
|||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
RecordFailure(target, authentication, ConnectionOutcome.Failed);
|
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>
|
/// <summary>What a renderer that never attached is reported as.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
@@ -11603,6 +11763,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
ConnectionAttemptEventArgs attempt,
|
ConnectionAttemptEventArgs attempt,
|
||||||
ConnectionTarget target,
|
ConnectionTarget target,
|
||||||
HostAuthentication authentication,
|
HostAuthentication authentication,
|
||||||
|
PhaseReporter reporter,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// Not reported before the await: the tab is constructed with this step already running — see
|
// 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);
|
authentication.Credential);
|
||||||
|
|
||||||
var sessionId = await workspace
|
var sessionId = await workspace
|
||||||
.OpenSessionAsync(request, TerminalSize.Default, ReporterFor(attempt), cancellationToken)
|
.OpenSessionAsync(request, TerminalSize.Default, reporter, cancellationToken)
|
||||||
.ConfigureAwait(true);
|
.ConfigureAwait(true);
|
||||||
|
|
||||||
// The workspace has already opened a ticket for this session, with the address and the moment it
|
// 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),
|
Dialled(target, authentication),
|
||||||
facts?.Cipher ?? string.Empty,
|
facts?.Cipher ?? string.Empty,
|
||||||
facts?.HostKeyAlgorithm ?? 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
|
// 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.
|
// 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_PASTE = 6;
|
||||||
const SERVER_FONT_SIZE = 7;
|
const SERVER_FONT_SIZE = 7;
|
||||||
const SERVER_CLIPBOARD = 8;
|
const SERVER_CLIPBOARD = 8;
|
||||||
|
const SERVER_OUTPUT_DROPPED = 9;
|
||||||
|
|
||||||
const CLIENT_INPUT = 1;
|
const CLIENT_INPUT = 1;
|
||||||
const CLIENT_ACKNOWLEDGE = 2;
|
const CLIENT_ACKNOWLEDGE = 2;
|
||||||
@@ -534,6 +535,24 @@ function handleFrame(buffer) {
|
|||||||
break;
|
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: {
|
case SERVER_SESSION_ACTIVATED: {
|
||||||
const session = sessions.get(sessionId);
|
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;
|
namespace DodoSSH.Client.Terminal;
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// This is what makes <c>yes</c> survivable. A terminal emulator renders at 60 Hz at best, while a
|
/// A terminal emulator renders at 60 Hz at best, while a remote process can produce output as fast as
|
||||||
/// remote process can produce output as fast as the network allows. Without a limit the difference
|
/// the network allows. The difference has to accumulate somewhere, and the one place it must never
|
||||||
/// accumulates somewhere — an unbounded queue in the client, or an ever-growing scrollback — and the
|
/// accumulate is inside the WebView: xterm queues whatever it is written and nothing on this side can
|
||||||
/// application's memory grows until it dies.
|
/// see how deep that queue is, so a page told to draw faster than it can grows until the tab dies.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The mechanism: the renderer is granted a window of bytes it is allowed to be behind by. Each byte
|
/// 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
|
/// 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
|
/// credit reaches zero the pump sends nothing more until some comes back.
|
||||||
/// 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.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The window has to be large enough that a normal burst never stalls and small enough to bound
|
/// ◆ <b>What this does not do is bound what the remote has sent</b>, and it used to be written as
|
||||||
/// memory. 256 KiB is roughly a screenful of dense output many times over, and it caps a session's
|
/// though it did — the claim was that a pump which stops reading closes SSH's own receive window and
|
||||||
/// in-flight cost at a quarter of a megabyte.
|
/// 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>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class CreditWindow
|
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>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
Clipboard = 8,
|
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>
|
/// <summary>Frames the renderer sends to the host.</summary>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
using System.Buffers;
|
using System.Buffers;
|
||||||
|
using System.Globalization;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading.Channels;
|
|
||||||
using DodoSSH.Client.Ssh;
|
using DodoSSH.Client.Ssh;
|
||||||
|
|
||||||
namespace DodoSSH.Client.Terminal;
|
namespace DodoSSH.Client.Terminal;
|
||||||
@@ -49,6 +49,16 @@ public sealed class TerminalPumpOptions
|
|||||||
|
|
||||||
/// <summary>How far behind the renderer may fall, in bytes.</summary>
|
/// <summary>How far behind the renderer may fall, in bytes.</summary>
|
||||||
public int WindowBytes { get; init; } = CreditWindow.DefaultWindowBytes;
|
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>
|
/// <summary>
|
||||||
@@ -56,27 +66,45 @@ public sealed class TerminalPumpOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Credit is reserved before reading, never after. That ordering is the whole design: because the pump
|
/// ◆ <b>The pump reads the shell channel continuously and never stops, which is deliberate rather than
|
||||||
/// cannot read more than the renderer has room for, the coalescing buffer is bounded by the credit
|
/// careless.</b> It used to stop as soon as the renderer fell behind, on the reasoning that a reader
|
||||||
/// window rather than by how fast the remote can talk. Reserving after reading would leave an
|
/// which does not read closes SSH's receive window, blocks the remote <c>sshd</c> on write, and blocks
|
||||||
/// unbounded queue between the socket and the screen, which is the failure mode this exists to
|
/// the process producing the output in turn — backpressure all the way to the source, with no custom
|
||||||
/// prevent.
|
/// 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>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// When credit runs out the pump stops reading. SSH's own receive window then closes, the remote
|
/// So there are two bounds now, each enforced where it can be. The backlog bounds what is waiting to be
|
||||||
/// <c>sshd</c> blocks on write, and the process producing output blocks in turn — backpressure all the
|
/// sent, by dropping its oldest bytes and saying so. The credit window bounds what has been sent and not
|
||||||
/// way to the source, with no custom protocol.
|
/// 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>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class TerminalSessionPump : IAsyncDisposable
|
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 uint sessionId;
|
||||||
private readonly ISshShellSession session;
|
private readonly ISshShellSession session;
|
||||||
private readonly ITerminalTransport transport;
|
private readonly ITerminalTransport transport;
|
||||||
private readonly TimeProvider clock;
|
private readonly TimeProvider clock;
|
||||||
private readonly TerminalPumpOptions options;
|
private readonly TerminalPumpOptions options;
|
||||||
private readonly Channel<byte[]> pending = Channel.CreateUnbounded<byte[]>(
|
private readonly OutputBacklog backlog;
|
||||||
new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
|
|
||||||
|
|
||||||
private readonly CancellationTokenSource lifetime = new();
|
private readonly CancellationTokenSource lifetime = new();
|
||||||
private int disposed;
|
private int disposed;
|
||||||
@@ -104,6 +132,7 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
|||||||
this.options = options ?? new TerminalPumpOptions();
|
this.options = options ?? new TerminalPumpOptions();
|
||||||
|
|
||||||
Credits = new CreditWindow(this.options.WindowBytes);
|
Credits = new CreditWindow(this.options.WindowBytes);
|
||||||
|
backlog = new OutputBacklog(this.options.BacklogBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>This session's flow-control window.</summary>
|
/// <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>
|
/// <summary>Total bytes read from the remote, for the throughput harness.</summary>
|
||||||
public long BytesRead { get; private set; }
|
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>
|
/// <summary>Total frames sent to the renderer, for the throughput harness.</summary>
|
||||||
public long FramesSent { get; private set; }
|
public long FramesSent { get; private set; }
|
||||||
|
|
||||||
@@ -152,31 +194,7 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
|||||||
var reader = ReadLoopAsync(linked.Token);
|
var reader = ReadLoopAsync(linked.Token);
|
||||||
var flusher = FlushLoopAsync(linked.Token);
|
var flusher = FlushLoopAsync(linked.Token);
|
||||||
|
|
||||||
TerminalSessionOutcome outcome;
|
var outcome = await ReadUntilItEndsAsync(reader).ConfigureAwait(false);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Before the drain and the last frame, so that anything reading this off a completed run — the
|
// 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.
|
// 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
|
// 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
|
// a remote writes is often the most important, and dropping it makes a clean exit look like a
|
||||||
// crash.
|
// 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
|
try
|
||||||
{
|
{
|
||||||
@@ -203,6 +231,42 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
|||||||
.ConfigureAwait(false);
|
.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>
|
/// <summary>Forwards keystrokes to the remote.</summary>
|
||||||
public ValueTask WriteInputAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken) =>
|
public ValueTask WriteInputAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken) =>
|
||||||
session.WriteAsync(data, 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.
|
// Unblocks anything waiting on credit that will now never be acknowledged.
|
||||||
Credits.Reset();
|
Credits.Reset();
|
||||||
|
|
||||||
pending.Writer.TryComplete();
|
backlog.Complete();
|
||||||
lifetime.Dispose();
|
lifetime.Dispose();
|
||||||
|
|
||||||
await session.DisposeAsync().ConfigureAwait(false);
|
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)
|
private async Task ReadLoopAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var buffer = ArrayPool<byte>.Shared.Rent(options.ReadBufferBytes);
|
var buffer = ArrayPool<byte>.Shared.Rent(options.ReadBufferBytes);
|
||||||
|
|
||||||
try
|
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);
|
var read = await session
|
||||||
if (granted == 0)
|
.ReadAsync(buffer.AsMemory(0, options.ReadBufferBytes), cancellationToken)
|
||||||
{
|
.ConfigureAwait(false);
|
||||||
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);
|
|
||||||
|
|
||||||
if (read == 0)
|
if (read == 0)
|
||||||
{
|
{
|
||||||
@@ -272,7 +329,10 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
BytesRead += read;
|
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
|
finally
|
||||||
@@ -283,65 +343,160 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
|||||||
|
|
||||||
private async Task FlushLoopAsync(CancellationToken cancellationToken)
|
private async Task FlushLoopAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var segments = new List<byte[]>();
|
while (await backlog.WaitForOutputAsync(cancellationToken).ConfigureAwait(false))
|
||||||
|
|
||||||
while (await pending.Reader.WaitToReadAsync(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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
segments.Clear();
|
var sent = await SendFromBacklogAsync(granted, cancellationToken).ConfigureAwait(false);
|
||||||
segments.Add(first);
|
|
||||||
|
|
||||||
// Coalesce for one frame's worth of time, then send everything at once. Whatever the
|
if (granted > sent)
|
||||||
// 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))
|
|
||||||
{
|
{
|
||||||
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;
|
while (await SendFromBacklogAsync(MaximumFrameBytes, CancellationToken.None)
|
||||||
foreach (var segment in segments)
|
.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);
|
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++;
|
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(
|
private async ValueTask SendAsync(
|
||||||
TerminalServerOpcode opcode,
|
TerminalServerOpcode opcode,
|
||||||
ReadOnlyMemory<byte> payload,
|
ReadOnlyMemory<byte> payload,
|
||||||
|
|||||||
@@ -209,11 +209,12 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Not <c>sessions.Count</c>, which over-reports. Nothing removes an entry when the remote closes
|
/// Not <c>sessions.Count</c>, which over-reports. Nothing removes an entry when the remote closes the
|
||||||
/// the channel on its own — <see cref="RunSessionAsync"/> only drops the renderer registration — so
|
/// channel on its own — <see cref="ReleaseAsync"/> gives back what that session was holding but leaves
|
||||||
/// a session whose shell exited half an hour ago is still in the dictionary. A completed
|
/// the entry, so the tab can go on saying it ended — and a session whose shell exited half an hour ago
|
||||||
/// <c>Run</c> task is what "the shell is gone" actually looks like: the pump's loops have finished
|
/// is therefore still in the dictionary. A completed <c>Run</c> task is what "the shell is gone"
|
||||||
/// and it has already sent <c>SessionClosed</c> to the renderer.
|
/// actually looks like: the pump's loops have finished and it has already sent <c>SessionClosed</c> to
|
||||||
|
/// the renderer.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// This exists because the shell shows the number on the unlock screen, and a lock screen that
|
/// 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
|
/// 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.
|
/// strip, and a tab that closed while a key was in flight is ordinary rather than exceptional.
|
||||||
/// </para>
|
/// </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>
|
/// </remarks>
|
||||||
/// <param name="sessionId">The terminal to type into.</param>
|
/// <param name="sessionId">The terminal to type into.</param>
|
||||||
/// <param name="data">Raw bytes, already encoded as the remote expects them.</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)
|
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);
|
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
|
// 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
|
// 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
|
// 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
|
// Neither SessionEnded nor the release of what the session held is done from here, and both
|
||||||
// AnnounceEndedAsync for what was wrong with that.
|
// used to be candidates for it — see AnnounceEndedAsync, which is where they ended up and why.
|
||||||
ConnectionLog?.Closed(sessionId, clock.GetUtcNow());
|
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>
|
/// <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="sessionId">The session, as the renderer knows it.</param>
|
||||||
/// <param name="pump">
|
/// <param name="pump">
|
||||||
@@ -898,12 +973,25 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
|||||||
announce = sessions.ContainsKey(sessionId);
|
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
|
// Read after the await, which is the only point at which the pump has settled it — see
|
||||||
// TerminalSessionPump.Outcome.
|
// TerminalSessionPump.Outcome.
|
||||||
SessionEnded?.Invoke(this, new TerminalSessionEndedEventArgs(sessionId, pump.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>
|
/// <summary>The address as dialled, for the log.</summary>
|
||||||
|
|||||||
@@ -51,6 +51,18 @@ internal static class LayoutHarness
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal const double HostDrawerWidth = 320;
|
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>
|
/// <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>
|
/// <remarks>v5b: 190 became 255, the design's own number rather than this bar's old approximation.</remarks>
|
||||||
internal const double NavRailWidth = 255;
|
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);
|
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 ----
|
// ---- The logs screen ----
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -2043,6 +2086,40 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
},
|
},
|
||||||
Token);
|
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>
|
/// <summary>Lays the host-key decision out in the rectangle it is drawn over.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <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>
|
/// </remarks>
|
||||||
internal List<SshConnectionRequest> SftpRequests { get; } = [];
|
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 />
|
/// <inheritdoc />
|
||||||
public async Task<ISshConnection> ConnectAsync(
|
public async Task<ISshConnection> ConnectAsync(
|
||||||
SshConnectionRequest request,
|
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
|
// 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
|
// 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.
|
// 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)
|
if (Gate is { } gate)
|
||||||
{
|
{
|
||||||
@@ -419,3 +449,42 @@ internal sealed class FakeSshShellSession : ISshShellSession
|
|||||||
closed.Dispose();
|
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>
|
/// </remarks>
|
||||||
private readonly FakeSshConnectionFactory ssh = new();
|
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;
|
private int signInAttempts;
|
||||||
|
|
||||||
/// <summary>How many times a shell has tried to resume a remembered sign-in, and with what.</summary>
|
/// <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;
|
save = onChanged;
|
||||||
return new StopWatching(() => save = null);
|
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;
|
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");
|
"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>
|
/// <remarks>
|
||||||
/// A connection that was refused is worth retrying for the same reason a dropped one is, and the
|
/// 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.
|
/// 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");
|
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>
|
/// <remarks>
|
||||||
/// A refusal has to end up somewhere the user will see it, and by the time one arrives they are quite
|
/// 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,
|
/// 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);
|
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.NewHostCommand.Execute(null);
|
||||||
vault.EditorLabel = label;
|
vault.EditorLabel = label;
|
||||||
vault.EditorHostname = "db.internal";
|
vault.EditorHostname = hostname;
|
||||||
vault.EditorUsername = "deploy";
|
vault.EditorUsername = "deploy";
|
||||||
|
|
||||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||||
@@ -10313,6 +10484,14 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
|||||||
private static HostRowViewModel Host(VaultViewModel vault, string label) =>
|
private static HostRowViewModel Host(VaultViewModel vault, string label) =>
|
||||||
vault.Hosts.Single(row => string.Equals(row.Label, label, StringComparison.Ordinal));
|
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>
|
/// <summary>Deletes the selected host: the question, and then the answer to it.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Both halves, because both are what deleting anything now takes — arming on its own changes nothing,
|
/// 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");
|
"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>
|
/// <summary>Trusts the relay's identity, then connects through it.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Pinned under the relay's own address, which is the honest thing here: what is under examination is a
|
/// 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 Lock gate = new();
|
||||||
|
|
||||||
private readonly bool blockReads;
|
private readonly bool blockReads;
|
||||||
|
private readonly TimeSpan? pacePerRead;
|
||||||
|
|
||||||
private long remaining;
|
private long remaining;
|
||||||
private byte pattern;
|
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
|
/// read loop racing the test for control of the pump's credit window — see the reattach tests in
|
||||||
/// <c>TerminalWorkspaceTests</c>.
|
/// <c>TerminalWorkspaceTests</c>.
|
||||||
/// </param>
|
/// </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;
|
remaining = bytesToProduce;
|
||||||
this.blockReads = blockReads;
|
this.blockReads = blockReads;
|
||||||
|
this.pacePerRead = pacePerRead;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -81,6 +98,11 @@ internal sealed class FakeShellSession : ISshShellSession
|
|||||||
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false);
|
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pacePerRead is { } pace)
|
||||||
|
{
|
||||||
|
await Task.Delay(pace, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
await Task.Yield();
|
await Task.Yield();
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
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>
|
/// <summary>Records frames, and can acknowledge them to keep credit flowing.</summary>
|
||||||
internal sealed class RecordingTransport : ITerminalTransport
|
internal sealed class RecordingTransport : ITerminalTransport
|
||||||
{
|
{
|
||||||
@@ -270,6 +326,23 @@ internal sealed class RecordingTransport : ITerminalTransport
|
|||||||
return [.. output];
|
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>
|
/// <summary>Counts frames of one opcode.</summary>
|
||||||
internal int CountOf(TerminalServerOpcode opcode) =>
|
internal int CountOf(TerminalServerOpcode opcode) =>
|
||||||
Frames.Count(frame =>
|
Frames.Count(frame =>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
using DodoSSH.Client.Ssh;
|
using DodoSSH.Client.Ssh;
|
||||||
|
|
||||||
namespace DodoSSH.Client.Terminal.Tests;
|
namespace DodoSSH.Client.Terminal.Tests;
|
||||||
@@ -84,13 +85,22 @@ public sealed class TerminalSessionPumpTests
|
|||||||
await run;
|
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]
|
[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 Window = 4 * 1024;
|
||||||
|
const int Backlog = 64 * 1024;
|
||||||
|
|
||||||
await using var session = new FakeShellSession(bytesToProduce: long.MaxValue);
|
await using var session = new FakeShellSession(bytesToProduce: long.MaxValue);
|
||||||
var transport = new RecordingTransport();
|
var transport = new RecordingTransport();
|
||||||
@@ -104,19 +114,32 @@ public sealed class TerminalSessionPumpTests
|
|||||||
ReadBufferBytes = 1024,
|
ReadBufferBytes = 1024,
|
||||||
FlushInterval = TimeSpan.FromMilliseconds(5),
|
FlushInterval = TimeSpan.FromMilliseconds(5),
|
||||||
WindowBytes = Window,
|
WindowBytes = Window,
|
||||||
|
BacklogBytes = Backlog,
|
||||||
});
|
});
|
||||||
|
|
||||||
var run = pump.RunAsync(TestContext.Current.CancellationToken);
|
var run = pump.RunAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
await Task.Delay(TimeSpan.FromMilliseconds(300), TestContext.Current.CancellationToken);
|
await Task.Delay(TimeSpan.FromMilliseconds(300), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
pump.BytesRead.ShouldBe(
|
var readByNow = pump.BytesRead;
|
||||||
Window,
|
|
||||||
"The pump read past its credit window, so output accumulates without bound.");
|
|
||||||
|
|
||||||
// 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);
|
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);
|
pump.Credits.Available.ShouldBe(0);
|
||||||
|
|
||||||
@@ -125,10 +148,13 @@ public sealed class TerminalSessionPumpTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AcknowledgingCredit_LetsThePumpResume()
|
public async Task AcknowledgingCredit_LetsMoreOutputThrough()
|
||||||
{
|
{
|
||||||
const int Window = 4 * 1024;
|
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);
|
await using var session = new FakeShellSession(bytesToProduce: long.MaxValue);
|
||||||
var transport = new RecordingTransport();
|
var transport = new RecordingTransport();
|
||||||
|
|
||||||
@@ -145,13 +171,16 @@ public sealed class TerminalSessionPumpTests
|
|||||||
var run = pump.RunAsync(TestContext.Current.CancellationToken);
|
var run = pump.RunAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
await Task.Delay(TimeSpan.FromMilliseconds(200), 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);
|
pump.Acknowledge(2048);
|
||||||
|
|
||||||
await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
|
await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
pump.BytesRead.ShouldBe(Window + 2048);
|
transport.OutputBytes().Length.ShouldBe(sentBeforeAcknowledgement + 2048);
|
||||||
|
|
||||||
await pump.DisposeAsync();
|
await pump.DisposeAsync();
|
||||||
await run;
|
await run;
|
||||||
@@ -161,9 +190,10 @@ public sealed class TerminalSessionPumpTests
|
|||||||
public async Task AnOverLargeAcknowledgement_DoesNotWidenTheWindow()
|
public async Task AnOverLargeAcknowledgement_DoesNotWidenTheWindow()
|
||||||
{
|
{
|
||||||
// The acknowledgement comes from JavaScript. A page claiming to have rendered a gigabyte must
|
// 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;
|
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);
|
await using var session = new FakeShellSession(bytesToProduce: long.MaxValue);
|
||||||
var transport = new RecordingTransport();
|
var transport = new RecordingTransport();
|
||||||
|
|
||||||
@@ -185,7 +215,8 @@ public sealed class TerminalSessionPumpTests
|
|||||||
await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
|
await Task.Delay(TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
// One further window's worth at most, never everything the endless producer could offer.
|
// 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 pump.DisposeAsync();
|
||||||
await run;
|
await run;
|
||||||
@@ -344,7 +375,11 @@ public sealed class TerminalSessionPumpTests
|
|||||||
const int Window = CreditWindow.DefaultWindowBytes;
|
const int Window = CreditWindow.DefaultWindowBytes;
|
||||||
|
|
||||||
await using var session = new FakeShellSession(bytesToProduce: Target);
|
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(
|
await using var pump = CreatePump(
|
||||||
session,
|
session,
|
||||||
@@ -369,7 +404,15 @@ public sealed class TerminalSessionPumpTests
|
|||||||
var peakOutstanding = await sampler;
|
var peakOutstanding = await sampler;
|
||||||
|
|
||||||
pump.BytesRead.ShouldBe(Target);
|
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;
|
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."));
|
$"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>
|
/// <summary>Watches in-flight bytes for the duration of a run, returning the highest seen.</summary>
|
||||||
private static async Task<int> SamplePeakOutstandingAsync(
|
private static async Task<int> SamplePeakOutstandingAsync(
|
||||||
TerminalSessionPump pump,
|
TerminalSessionPump pump,
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ public sealed class TerminalWorkspaceTests
|
|||||||
/// <summary>How long <see cref="WaitUntilAsync"/> polls before calling it a failure.</summary>
|
/// <summary>How long <see cref="WaitUntilAsync"/> polls before calling it a failure.</summary>
|
||||||
private static readonly TimeSpan PollTimeout = TimeSpan.FromSeconds(10);
|
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]
|
[Fact]
|
||||||
public async Task WaitingForARendererThatNeverAttaches_GivesUp()
|
public async Task WaitingForARendererThatNeverAttaches_GivesUp()
|
||||||
{
|
{
|
||||||
@@ -83,9 +89,9 @@ public sealed class TerminalWorkspaceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The case a naive <c>sessions.Count</c> gets wrong. Nothing removes the entry when the remote
|
/// The case a naive <c>sessions.Count</c> gets wrong. Nothing removes the entry when the remote closes
|
||||||
/// closes the channel by itself — the session is still in the dictionary, holding a connection that
|
/// the channel by itself — what it held is given back, but the entry stays so the tab can go on saying
|
||||||
/// is finished — so counting entries would report a shell that exited as still running. A user
|
/// 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.
|
/// deciding whether it is safe to walk away is the person that lie is told to.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -645,6 +651,86 @@ public sealed class TerminalWorkspaceTests
|
|||||||
await workspace.SendInputAsync(sessionId, "x"u8.ToArray(), TestContext.Current.CancellationToken));
|
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">
|
/// <param name="connections">
|
||||||
/// How connections are made. The renderer-gate tests never reach it — they stop at the gate — but they
|
/// 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.
|
/// 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>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <c>WaitForRendererAsync</c> answers only for the first renderer ever to attach, so a second call
|
/// <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
|
/// returns immediately without proving anything about the second socket — and even for the first it
|
||||||
/// not luck: the only frames a second socket is given are the replay, and the replay is *caused* by the
|
/// answers too early for what the callers here need. It completes when the socket is attached, which
|
||||||
/// attach — <see cref="TerminalWorkspace.RendererReattached"/> and the frames before it cannot be sent
|
/// is before <see cref="TerminalWorkspace.ReplayAfterAttachAsync"/> has run: that is fired and
|
||||||
/// early. The day a test sends something else down a reattached socket, this needs the data plane's own
|
/// forgotten from the accept thread, and a thread-pool that deschedules it for a timeslice lets the
|
||||||
/// <c>SocketAttached</c>, which the workspace does not forward today.
|
/// 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>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static async Task<ClientWebSocket> ConnectRendererAsync(TerminalWorkspace workspace)
|
private static async Task<ClientWebSocket> ConnectRendererAsync(TerminalWorkspace workspace)
|
||||||
@@ -715,17 +810,29 @@ public sealed class TerminalWorkspaceTests
|
|||||||
"Origin",
|
"Origin",
|
||||||
string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{workspace.PageUrl.Port}"));
|
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
|
try
|
||||||
{
|
{
|
||||||
await client.ConnectAsync(new Uri(socketUrl), TestContext.Current.CancellationToken);
|
await client.ConnectAsync(new Uri(socketUrl), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
await workspace.WaitForRendererAsync(TestContext.Current.CancellationToken);
|
await workspace.WaitForRendererAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await replayed.Task.WaitAsync(ReplayTimeout, TestContext.Current.CancellationToken);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
client.Dispose();
|
client.Dispose();
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
workspace.RendererReattached -= OnReattached;
|
||||||
|
}
|
||||||
|
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user