Keep idle SSH tunnel sessions alive with a websocket ping - #6358
Keep idle SSH tunnel sessions alive with a websocket ping#6358anton-107 wants to merge 6 commits into
Conversation
An idle `databricks ssh connect` session dies after roughly nine minutes. Both proxy loops are purely data-driven, so a session nobody is typing into puts no frames on the websocket at all, and the server side reaps the stream it then considers dead (websocket close 4000, Armeria ClosedStreamException). Setting `ServerAliveInterval` in the SSH client config works around it entirely, because SSH-level keepalives are real payload bytes that the sending loop forwards — which is what pinned the diagnosis on the transport. The client proxy now pings the websocket every 20 seconds for the life of the connection, as an additional goroutine in the errgroup that already drives the periodic handover tick. That placement makes the keepalive client-only by construction: the server never calls RunClientProxy, so no flag can enable server-side pinging, and the cluster-side binary needs no redeployment. Pings take the proxy's existing serialised write path (sendMessage), which already holds the handover mutex, so the serialisation gorilla/websocket requires is inherited rather than newly built. A ping that ticks during a handover blocks and goes out late; a handover establishes a fresh connection, so the peer's idle clock resets anyway. A failed ping is logged at debug level and never returned: the receiving loop stays the sole authority on whether the connection is dead, and an error here would cancel the session the keepalive exists to preserve. Liveness posture is keep-warm only — no read or write deadlines, and the pong handler only logs. The tunnel already rotates its websocket on a schedule via the periodic handover, so hostility to long-lived streams on this transport was already known and designed around here; the keepalive is the missing half of that story. Also corrects the `ssh server --shutdown-delay` help text, which claimed the server shuts down "after no pings from clients" when no pings existed anywhere in the tunnel — inaccurate today, and actively misleading once real pings exist. Co-authored-by: Isaac <no-reply@databricks.com>
End-to-end verification against dogfood showed the far end never returns a pong: across 21-minute, 45-minute and forced-handover runs the pong handler logged nothing, while ping writes never failed. Control frames do not make the round trip on this transport, and the outbound ping alone is what keeps the stream from being reaped. That leaves a support engineer reading a customer's debug log with no positive evidence that keepalives were flowing — only the absence of failures, which is indistinguishable from a build without the keepalive. Log each successful ping instead, at debug level: three lines a minute on a transport whose debug log already carries full HTTP bodies. Verified end to end: ping lines appear at exactly 20-second intervals on an idle session, pong lines remain absent. Co-authored-by: Isaac <no-reply@databricks.com>
Integration test reportCommit: f2086b3
7 interesting tests: 4 SKIP, 2 flaky, 1 RECOVERED
Top 3 slowest tests (at least 2 minutes):
|
|
Should-fix: the keepalive ping can block shutdown/handover for up to ~15 min on a stalled connection The keepalive ping is sent via Severity: this is self-healing (the write eventually errors, the mutex releases, Suggested fix (small, and arguably the cleaner design): send pings via Missing test: the current suite proves "handover blocks ping" and "write fails fast" (deadline set in the past → immediate error), but not the ordering that actually deadlocks: a ping parked in a blocking write while Everything else looks good — this is the one item worth addressing before merge (or tracking as a fast follow-up if bounded shutdown latency isn't a hard requirement for This comment was generated with GitHub MCP. |
Review of #6358 pointed out that routing pings through sendMessage puts an unbounded write on the connection's shared write path. WriteMessage takes the handover mutex and sets no deadline, and close() needs that same mutex, so a ping that parks on a stalled or half-open socket — a full send buffer, no RST — holds up the closing handshake until the kernel abandons its retransmits, roughly 15 minutes with Linux defaults. Context cancellation cannot interrupt a goroutine parked in a blocking write. The probability is low for a purely idle session, whose 8-byte control frames go into a near-empty buffer, but not for the path this feature exists to serve: data flows, the peer vanishes mid-transfer leaving unacked bytes in the buffer, the session goes idle, and the next ping parks. That is the feature's own domain, so the exposure belongs to this change even though the hazard predates it on the data path. Pings now go out with WriteControl on the loaded connection, taking no handover mutex. gorilla explicitly permits WriteControl concurrently with the data writes, and its deadline bounds both the wait for the connection's write lock and the socket write itself, so a stalled ping can hold that lock for at most proxyPingWriteTimeout instead of minutes. The handover path is fully decoupled: a ping that ticks during a rotation goes to the connection being replaced and may simply fail, which is already non-fatal. This drops the "single concurrent writer" rationale for the mutex, which applies to WriteMessage and not to WriteControl, and adds a write deadline the original design ruled out. The rule it was protecting — a keepalive must never end a session — is untouched: a timed-out ping is logged at debug and the ticker continues. Tests: the two that asserted the mutex path were reworked, since one drove sendMessage directly and the other's past write deadline is now overridden by WriteControl's own. A ping is now asserted to complete during an in-flight handover rather than to block on it, its failure is induced at the socket, and a new case parks a ping in the socket write and requires the closing handshake to finish within the ping's deadline. Verified end to end: 10 pings at exact 20-second intervals across a 200-second idle session on dogfood, no failures, session intact. Co-authored-by: Isaac <no-reply@databricks.com>
|
Good catch — fixed in bede575. I verified both halves of the claim against gorilla's source before acting, and the diagnosis holds: Pings now use Two refinements to the framing, for the record: It bounds the coupling rather than removing it. Two of the existing tests were asserting the old path, so they were reworked rather than added to:
On the new parked-write test. Wrote it as you suggested and it exposed something worth knowing: asserting on the session's shutdown doesn't isolate the ping. If the silently-dead-peer shutdown hang is worth chasing, it wants its own issue — it needs an out-of-band Re-verified end to end on dogfood after the change: 10 pings at exact 20-second intervals across a 200-second idle session, zero failures, session intact. Proxy package green under |
|
Nit (non-blocking): the PR description is stale relative to the shipped implementation and is worth refreshing before merge so reviewers reading top-down aren't misled. It still describes the superseded mutex-based design — "pings go through `sendMessage`, which already holds the handover mutex" — and references tests that no longer exist (`TestKeepalivePingBlockedByHandoverDoesNotDeadlock`, "TestHandover is now run twice"). The shipped code (commit `bede5759e`) instead sends pings via `WriteControl` off the handover mutex with a 5s write deadline, and the actual tests are `TestKeepalivePing{ReachesServer,FailureDoesNotEndSession,ParkedInWriteDoesNotStallClose,DuringHandoverDoesNotDisruptIt}`. The code and commit messages are consistent — only the PR description drifted. |
The keepalive no longer goes through sendMessage, so pings and the data stream share the connection's write lock rather than the proxy's serialised write path. The property the subtest protects is unchanged. Co-authored-by: Isaac <no-reply@databricks.com>
|
Fixed — description rewritten to match what shipped. Corrected: the mutex-based mechanism bullet now describes Also added the You checked and said code and commit messages were consistent — nearly. One test comment had drifted the same way ("pings share the proxy's serialised write path", which is no longer where they go), fixed in 88dcba3. |
…session Independent verification (recorded on DECO-28186) found that the PR's claim "a failed ping never ends a session" was only true of the errgroup, not of gorilla's connection state. Any failed write puts a gorilla connection into a permanent write-error state, so one timed-out keepalive ping stops every later write — data frames and the close frame alike. The session then died through the data path instead: data written afterwards never arrived, and because the close message could not go out, the peer never closed the connection, so the receiving loop stayed blocked in ReadMessage and g.Wait never returned. A silent hang, which is the very symptom this change exists to remove. The teardown goroutine already documents the fix as its intent — "we close the connection and the source ... to unblock them" — but pc.close only sends a close message, which needs a live peer to act on it. It now closes the connection too, so both loops unblock and the session exits with the sending loop's error instead of hanging. The ping failure itself stays non-fatal, and deliberately so: reads are unaffected by a poisoned write path and may still be delivering output the user is waiting on — a long job that prints nothing for minutes is one of the cases this feature was written for. Ending the session there would cut off data that is still arriving. It is logged at warn rather than debug, since it now means the tunnel can no longer send anything. Tests, after mutation-testing every assertion: - The parked-write fake modelled a deadline-less write as an instant failure, the opposite of an unbounded park, so the test could not tell the bounded write path from the one it replaced. It now parks such a write for longer than any assertion, and the reverted-to-sendMessage mutant fails. - TestKeepalivePingFailureDoesNotEndSession was vacuous: the fake's broken close handshake meant the session could not end for reasons unrelated to the keepalive. Closing the connection on teardown restores its power — a mutant that makes a failed ping fatal now fails it. - A t.Fatal in the handover test left the dial hook parked, and cleanup, which waits on the proxy loops, then deadlocked and took the package down with a timeout panic. The release is now deferred after cleanup so it runs first; the same mutant is caught in 5s as one clean failure. - New: TestKeepalivePingFailureDoesNotHangTheSession asserts a session whose write path has been poisoned by a failed ping ends promptly with an error. All five keepalive assertions now kill their mutants. Verified end to end on dogfood that a normal exit is still clean after the teardown change: exit 0, 70s idle, 3 pings, no warnings, no spurious disconnect message. Co-authored-by: Isaac <no-reply@databricks.com>
|
Defect confirmed and fixed in The defect. The diagnosis is exactly right: "a failed ping never ends a session" was true of the errgroup and false of gorilla's connection state. Fixed by taking the second of the two directions: the teardown goroutine now closes the websocket, not just sends a close message. Worth noting it already claimed to — its comment reads "we close the connection and the source ... to unblock them" — so this implements documented intent that was never written. Both loops unblock and the session exits with the sending loop's error. That also fixes the same hang for a peer that silently goes away, keepalive or not, which I had flagged as wanting its own issue; it turned out to be reachable through the keepalive, so it belongs here. Why the ping failure is still not fatal. I considered the first direction and rejected it, on a case the spec cares about: a poisoned write path leaves reads working, and "a long job that prints nothing for minutes, then produces output" is one of this feature's own user stories. Ending the session on a failed ping would cut off data still arriving fine. There is also a non-poisoning failure — Test power. All five assertions now kill their mutants; I re-ran your method to check:
Minor notes. Vite correction taken — the description no longer cites vite for the write deadline, only for the 20s interval. The 2s unconditional wait is now 1s. Left as-is with reasons: the zero-interval ticker panic (a panic at session start is the right failure mode for a programming error, and no live path reaches it), and Re-verified end to end on dogfood, since the teardown change is on the path every session exit takes: normal exit still clean at exit 0, 70s true idle, 3 pings at 20s spacing, no warnings, no spurious disconnect message. Unit suite green, |
| // The driver proxy does not return pongs (verified end to end), so this | ||
| // line is the only evidence in a customer's log that pings were flowing. |
There was a problem hiding this comment.
We should fix this. I fixed the driver proxy <-> server side, but not the client <-> driver proxy side
Changes
An idle
databricks ssh connectsession dies after roughly nine minutes, with no warning and no actionable message — the user sees a raw server-side exception (websocket close 4000, ArmeriaClosedStreamException) or simply a dropped connection, and loses whatever was in that shell. The tunnel is healthy the whole time; it dies purely because nothing was sent over it.Both proxy loops (
runSendingLoop/runReceivingLoop) are data-driven, so an idle SSH session puts no frames on the websocket at all and the server side reaps the stream it considers dead. SettingServerAliveInterval 30in the SSH client config works around it entirely (the reporting customer verified ~2 hours idle), because SSH-level keepalives are real payload bytes that the sending loop forwards — which is what pinned the diagnosis on the transport rather than on any CLI timer.The client proxy now pings the websocket every 20 seconds for the life of the connection, as an additional goroutine in the errgroup that already drives the periodic handover tick:
RunClientProxy, so there is no flag or conditional that could enable server-side pinging, and the cluster-side server binary needs no redeployment — existing clusters benefit as soon as users update their CLI.conn.WriteControl(websocket.PingMessage, nil, now+proxyPingWriteTimeout)(5s). The value is borrowed from vite'swsWriteTimeout, but note vite bounds a channel handoff there and sets no socket write deadline at all, so it is precedent for the number, not for the mechanism. gorilla permitsWriteControlconcurrently with the data writes, and its deadline bounds both the wait for the connection's write lock and the socket write itself. Routing pings throughsendMessageinstead — the first cut of this change — put an unbounded write on the shared write path:WriteMessagesets no deadline andclose()takes the same handover mutex, so a ping parked on a half-open socket (full send buffer, no RST) held up the closing handshake until the kernel abandoned its retransmits, ~15 min with Linux defaults. Thanks @rugpanov for catching that. No change to the proxy's write path, handover coordination, or connection swap.pc.closeonly sends a close message, which needs a live peer to react to it, and it cannot go out at all on a poisoned write path — so the receiving loop stayed blocked inReadMessageand the session hung instead of exiting. The teardown goroutine's comment already said it closed the connection; now it does. This also fixes the same hang for a peer that silently goes away, keepalive or not.The interval is an unexported 20s constant sitting beside the handover interval in
experimental/ssh/cmd/constants.go, injectable atRunClientProxyin the same style as the handover tick (which is already parameterised for testing). There is no user-facing flag: the server's idle threshold is undocumented, so a knob nobody can tune intelligently is worse than a good default. 20s matches the vite bridge's keepalive and sits well under both bounds we have — the ~9 minute observed failure and the verified-sufficient 30s SSH-level keepalive.Supporting context: the tunnel already rotates its websocket on a schedule via the periodic handover, so hostility to long-lived streams on this transport was already known and designed around here. The keepalive is the missing half of that same story rather than a new concern.
Alternative causes were ruled out and are recorded so nobody re-investigates them: the
--shutdown-delaytimer is stopped while a connection is registered (connections.go,TryAdd/Remove) so it cannot fire on a connected session; the periodic handover defaults to 30 minutes, not a value near nine; and the only other timer is the 30s initial handshake timeout, which applies to connection setup only. Nothing in the CLI fires near the observed nine minutes.Rides along: the
ssh server --shutdown-delayhelp text said the server shuts down "after no pings from clients", when no pings existed anywhere in the tunnel. It was inaccurate before this change and becomes actively misleading once real pings exist, so the one-line correction is included.Note on precedent:
libs/apps/vite/bridge.goruns the same 20s ping pattern, but not over the same transport — it connects to a dev-tunnel endpoint on the Databricks Apps domain, whereas the SSH tunnel connects to a driver-proxy path on the cluster. It is good evidence that a 20s ping is a working pattern in this repo against Databricks infrastructure; it is not evidence about the driver proxy's idle threshold or its reaping semantics.Out of scope, tracked separately: surfacing a websocket close 4000 as an actionable message instead of a raw Armeria exception (plus a dedicated telemetry error category — it currently lands as
unknown); any driver-proxy-side idle-timeout change; a configurable keepalive interval.Resolves DECO-28186.
Why
Idle-session drops are a product defect that currently requires client-side configuration knowledge (
ServerAliveInterval) to work around, for the tunnel's most ordinary use case: an open terminal nobody is typing into. A long-lived tunnel should heartbeat its own transport rather than depend on a server's tolerance, so this is the right fix regardless of what the driver proxy does.Tests
Five keepalive tests plus one reused, all asserting externally observable behaviour of the tunnel through the entry point users go through — never the internal shape of the keepalive goroutine:
TestKeepalivePingReachesServer— a ping actually arrives at the peer on a session that sends no data.TestKeepalivePingDuringHandoverDoesNotDisruptIt— a ping issued while a handover is held mid-dial completes without waiting for it, the handover still completes, and the tunnel still carries data on the connection the handover installed.TestKeepalivePingParkedInWriteDoesNotStallClose— a ping parked in the socket write must not hold up the closing handshake for longer than its deadline allows. The park is induced at the socket, through anet.Connwrapper injected viaDialer.NetDialContext, so gorilla's real locking and deadline handling stay in the test.TestKeepalivePingFailureDoesNotEndSession— every ping write fails (induced at the socket, leaving reads healthy) and the session must stay up.TestKeepalivePingFailureDoesNotHangTheSession— a session whose write path a failed ping has poisoned must end promptly with an error, not hang.TestHandoveris now run twice, once with keepalive active at a 1ms interval — pings interleaved with thousands of ordered messages across several handovers neither corrupt nor reorder the stream, and never trip gorilla's concurrent-write panic.Every one of these was mutation-tested — production code broken deliberately, to check the suite notices. All five kill their mutants: neutering the ping, making a failed ping fatal, reverting
sendPingto the unboundedsendMessagepath, putting the ping back on the handover mutex, and removing the teardown close. Two of them did not, before an independent review caught it: the parked-write fake modelled a deadline-less write as an instant failure (the opposite of an unbounded park), and the failure test was vacuous because the fake's broken close handshake meant the session could not end for reasons unrelated to the keepalive. Nothing assertslogPongs; on a transport that returns no pongs there is nothing to assert.An acceptance test that idles a websocket past a real timeout was deliberately not written: it would be slow and timing-flaky, and could still not prove the driver proxy stops reaping. The burden is split instead — unit tests prove the mechanism, a manual end-to-end run proves the outcome.
End-to-end verification (dogfood serverless)
Every run used an unpatched server binary built from
origin/mainand uploaded via--releases-dir(checked by inspection that it does not contain the change), so the runs differ in exactly one variable: whether the client pings. Idleness was held by a real SSH session running onlydate; sleep N; echo MARKER; date, with no SSH-level keepalive (nothing setsServerAliveInterval, and the ssh-to-ProxyCommand link is a pipe, soTCPKeepAlivecannot apply either).--handover-timeout=30s)WriteControlchangeTwo findings worth knowing when reviewing:
The hang that surfaced while testing — a peer that silently goes away leaves
g.Wait()waiting on the receiving loop forever — turned out to be reachable through the keepalive, so it is fixed here rather than deferred: one failed ping poisons the write path, which is enough to keep the close message from going out. That is the teardown close above.Full details and log excerpts are recorded on DECO-28186.
Full unit suite passes; the proxy package is green under
-race.go test ./acceptancepasses apart from three tests that fail for environment reasons on the machine used (thefipstest needs a FIPS-built binary, and two terraform-backed tests hit the 60s script timeout).This pull request and its description were written by Isaac.