fix SSH ping CPU spin and Linux releases

This commit is contained in:
Andy Dixon
2026-08-13 17:22:48 +01:00
parent 04c05e20bb
commit 696661bfa9
6 changed files with 259 additions and 27 deletions
@@ -222,11 +222,82 @@ async function testCompleteAppTeardownIsImmediateAndIdempotent() {
assert.strictEqual(app._closingSessions.size, 0);
}
async function testLatencyPingIsSingleFlightAndIgnoresStaleResults() {
const flushAsync = () => new Promise((resolve) => setImmediate(resolve));
let nextTimerID = 1;
const timers = new Map();
const latencyEl = { textContent: "", className: "" };
const context = loadScript("src/js/app.js", {
setTimeout: (callback) => {
const id = nextTimerID++;
timers.set(id, callback);
return id;
},
clearTimeout: (id) => timers.delete(id),
document: {
querySelector: () => null,
getElementById: (id) => (id === "status-latency" ? latencyEl : null),
addEventListener: () => {},
},
});
const App = context.window.NexusTerminal.App;
const app = Object.create(App.prototype);
app._latencySessionID = null;
app._latencyTimer = null;
app._latencyGeneration = 0;
app._latencyPingInFlight = false;
const pending = [];
context.window.NexusTerminal.wailsService = {
PingSession: (sessionID) =>
new Promise((resolve, reject) => {
pending.push({ sessionID, resolve, reject });
}),
};
app._startLatencyPing("session-a");
app._startLatencyPing("session-a");
assert.strictEqual(pending.length, 1, "an in-flight ping must not overlap");
assert.strictEqual(timers.size, 0, "the next ping waits for completion");
app._startLatencyPing("session-b");
assert.strictEqual(pending.length, 2);
pending[0].reject(new Error("stale session closed"));
await flushAsync();
assert.strictEqual(app._latencySessionID, "session-b");
assert.strictEqual(
app._latencyPingInFlight,
true,
"a stale rejection must not stop the current session ping",
);
pending[1].resolve(42);
await flushAsync();
assert.strictEqual(latencyEl.textContent, "42 ms");
assert.strictEqual(latencyEl.className, "latency-good");
assert.strictEqual(timers.size, 1, "one follow-up ping should be scheduled");
const followUp = timers.values().next().value;
timers.clear();
followUp();
assert.strictEqual(pending.length, 3);
assert.strictEqual(pending[2].sessionID, "session-b");
assert.strictEqual(timers.size, 0, "follow-up pings must also be single-flight");
pending[2].resolve(250);
await flushAsync();
assert.strictEqual(latencyEl.className, "latency-bad");
assert.strictEqual(timers.size, 1);
app._stopLatencyPing();
assert.strictEqual(timers.size, 0);
}
(async () => {
await testRepeatedSplitCleanup();
testClosedPaneRejectsLateConnection();
testPerSessionStoresReleaseIDs();
await testCompleteAppTeardownIsImmediateAndIdempotent();
await testLatencyPingIsSingleFlightAndIgnoresStaleResults();
console.log("Session lifecycle regression tests passed.");
})().catch((err) => {
console.error(err);
+45 -7
View File
@@ -49,6 +49,10 @@
this.leviathan = new LeviathanWorkbench(this);
/** @type {Map<string, Promise<void>>} Idempotent in-flight session closes. */
this._closingSessions = new Map();
this._latencySessionID = null;
this._latencyTimer = null;
this._latencyGeneration = 0;
this._latencyPingInFlight = false;
this.updateInfo = null;
this.mode = "nexus";
}
@@ -1362,17 +1366,44 @@
}
_startLatencyPing(sessionID) {
// Don't restart if already pinging the same session.
if (this._latencySessionID === sessionID && this._latencyTimer) return;
// Do not queue overlapping backend requests for the active session.
if (
this._latencySessionID === sessionID &&
(this._latencyTimer || this._latencyPingInFlight)
) {
return;
}
this._stopLatencyPing();
this._latencySessionID = sessionID;
const generation = this._latencyGeneration;
const isCurrent = () =>
this._latencyGeneration === generation &&
this._latencySessionID === sessionID;
const scheduleNext = () => {
if (!isCurrent() || this._latencyTimer) return;
this._latencyTimer = setTimeout(() => {
this._latencyTimer = null;
void doPing();
}, 10000);
};
const doPing = async () => {
if (!window.NexusTerminal?.wailsService) return;
if (!isCurrent()) return;
if (!window.NexusTerminal?.wailsService) {
scheduleNext();
return;
}
this._latencyPingInFlight = true;
let succeeded = false;
try {
const ms = await window.NexusTerminal.wailsService.PingSession(sessionID);
if (!isCurrent()) return;
succeeded = true;
const el = document.getElementById("status-latency");
if (el && this._latencySessionID === sessionID) {
if (el) {
el.textContent = ms + " ms";
el.className =
ms < 100
@@ -1382,6 +1413,7 @@
: "latency-bad";
}
} catch (_) {
if (!isCurrent()) return;
// Session may have disconnected — clear indicator.
const el = document.getElementById("status-latency");
if (el) {
@@ -1389,18 +1421,24 @@
el.className = "";
}
this._stopLatencyPing();
} finally {
if (isCurrent()) {
this._latencyPingInFlight = false;
if (succeeded) scheduleNext();
}
}
};
doPing();
this._latencyTimer = setInterval(doPing, 10000);
void doPing();
}
_stopLatencyPing() {
this._latencyGeneration = (this._latencyGeneration || 0) + 1;
if (this._latencyTimer) {
clearInterval(this._latencyTimer);
clearTimeout(this._latencyTimer);
this._latencyTimer = null;
}
this._latencyPingInFlight = false;
this._latencySessionID = null;
}
+4 -4
View File
@@ -16,9 +16,9 @@ require (
github.com/ulikunitz/xz v0.5.15
github.com/wailsapp/wails/v2 v2.12.0
github.com/zalando/go-keyring v0.2.8
golang.org/x/crypto v0.52.0
golang.org/x/net v0.55.0
golang.org/x/text v0.37.0
golang.org/x/crypto v0.55.0
golang.org/x/net v0.57.0
golang.org/x/text v0.41.0
modernc.org/sqlite v1.53.0
)
@@ -51,7 +51,7 @@ require (
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.23 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
+16 -16
View File
@@ -99,31 +99,31 @@ github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozH
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
+93
View File
@@ -1,6 +1,8 @@
package sshclient
import (
"crypto/ed25519"
"crypto/rand"
"net"
"testing"
"time"
@@ -57,3 +59,94 @@ func TestJumpHostClientConfigUsesFinalHostKeyCallback(t *testing.T) {
t.Fatal("final host key callback was not used")
}
}
func TestPingReturnsAfterSSHTransportCloses(t *testing.T) {
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("GenerateKey() error = %v", err)
}
signer, err := ssh.NewSignerFromKey(privateKey)
if err != nil {
t.Fatalf("NewSignerFromKey() error = %v", err)
}
serverConfig := &ssh.ServerConfig{NoClientAuth: true}
serverConfig.AddHostKey(signer)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Listen() error = %v", err)
}
t.Cleanup(func() { _ = listener.Close() })
serverReady := make(chan *ssh.ServerConn, 1)
serverErrors := make(chan error, 1)
go func() {
conn, acceptErr := listener.Accept()
if acceptErr != nil {
serverErrors <- acceptErr
return
}
serverConn, _, _, handshakeErr := ssh.NewServerConn(conn, serverConfig)
if handshakeErr != nil {
_ = conn.Close()
serverErrors <- handshakeErr
return
}
serverReady <- serverConn
}()
conn, err := net.DialTimeout("tcp", listener.Addr().String(), 2*time.Second)
if err != nil {
t.Fatalf("DialTimeout() error = %v", err)
}
clientConn, channels, requests, err := ssh.NewClientConn(conn, listener.Addr().String(), &ssh.ClientConfig{
User: "test",
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // Test server uses an ephemeral key.
})
if err != nil {
_ = conn.Close()
t.Fatalf("NewClientConn() error = %v", err)
}
rawClient := ssh.NewClient(clientConn, channels, requests)
var serverConn *ssh.ServerConn
select {
case serverConn = <-serverReady:
case serverErr := <-serverErrors:
t.Fatalf("SSH server handshake error = %v", serverErr)
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for SSH server handshake")
}
client := NewClient(&models.Profile{})
client.mu.Lock()
client.sshClient = rawClient
client.connected = true
client.mu.Unlock()
t.Cleanup(func() { _ = client.Close() })
if err := serverConn.Close(); err != nil {
t.Fatalf("server Close() error = %v", err)
}
waitDone := make(chan error, 1)
go func() { waitDone <- rawClient.Wait() }()
select {
case <-waitDone:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for SSH transport to close")
}
pingDone := make(chan error, 1)
go func() {
_, pingErr := client.Ping()
pingDone <- pingErr
}()
select {
case pingErr := <-pingDone:
if pingErr == nil {
t.Fatal("Ping() succeeded after the SSH transport closed")
}
case <-time.After(2 * time.Second):
t.Fatal("Ping() did not return after the SSH transport closed")
}
}
+30
View File
@@ -0,0 +1,30 @@
ARG GO_IMAGE
FROM ${GO_IMAGE}
ARG NODE_ARCH
ARG NODE_VERSION
ARG WAILS_VERSION
ENV PATH="/opt/node/bin:/go/bin:/usr/local/go/bin:${PATH}"
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates \
curl \
git \
libdigest-sha-perl \
libgtk-3-dev \
libwebkit2gtk-4.1-dev \
pkg-config \
xz-utils \
zip \
&& node_archive="node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \
&& curl -fsSLo "/tmp/${node_archive}" \
"https://nodejs.org/dist/v${NODE_VERSION}/${node_archive}" \
&& mkdir -p /opt/node \
&& tar -xJf "/tmp/${node_archive}" -C /opt/node --strip-components=1 \
&& rm -f "/tmp/${node_archive}" \
&& go install "github.com/wailsapp/wails/v2/cmd/wails@${WAILS_VERSION}" \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src