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 3 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>
Waiting for approvalBased on git history, these people are best suited to review:
Eligible reviewers: Suggestions based on git history. See OWNERS for ownership rules. |
Integration test reportCommit: bede575
7 interesting tests: 4 SKIP, 2 flaky, 1 RECOVERED
Top 18 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 |
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.sendMessage, which already holds the handover mutex, so the serialisation gorilla/websocket requires (it forbids concurrent writers) is inherited rather than newly built. No change to the proxy's write path, handover coordination, or connection swap.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
Four unit tests, 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.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.TestKeepalivePingBlockedByHandoverDoesNotDeadlock— a ping blocked on the handover mutex does not deadlock an in-flight handover. This is the assertion that earns its keep: it covers the invariant the design was reasoned to rather than observed. The handover is held mid-flight (blocked on its dial) so the ping is provably waiting on the mutex, then both are required to complete.TestKeepalivePingFailureDoesNotEndSession— every ping write fails (a write deadline in the past, set before the connection reaches the proxy, leaves reads healthy) and the session must stay up.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)Two findings worth knowing when reviewing:
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.