diff --git a/frontend/scripts/test-session-lifecycle.js b/frontend/scripts/test-session-lifecycle.js index b4a8ad9..f2b5880 100644 --- a/frontend/scripts/test-session-lifecycle.js +++ b/frontend/scripts/test-session-lifecycle.js @@ -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); diff --git a/frontend/src/js/app.js b/frontend/src/js/app.js index 3fdf308..520b30b 100644 --- a/frontend/src/js/app.js +++ b/frontend/src/js/app.js @@ -49,6 +49,10 @@ this.leviathan = new LeviathanWorkbench(this); /** @type {Map>} 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; } diff --git a/go.mod b/go.mod index 302309b..ff98e54 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 1b608c3..c8135ca 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/sshclient/sshclient_test.go b/internal/sshclient/sshclient_test.go index 8a4906c..2fa6831 100644 --- a/internal/sshclient/sshclient_test.go +++ b/internal/sshclient/sshclient_test.go @@ -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") + } +} diff --git a/scripts/linux-release.Dockerfile b/scripts/linux-release.Dockerfile new file mode 100644 index 0000000..6ae2c44 --- /dev/null +++ b/scripts/linux-release.Dockerfile @@ -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