-
Notifications
You must be signed in to change notification settings - Fork 233
Fix SSH session resume with bounded backpressure #6650
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
08ca4eb
Fix SSH session resume with bounded backpressure
anton-107 445ec66
Link SSH resume changelog to PR #6650
anton-107 c2b8ea2
Bound the resume waits that could outlive a session, and cover them
anton-107 29da087
Fix SSH resume recovery during handover and HTTP timeouts
anton-107 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| * Preserve SSH sessions across temporary tunnel disconnects, with bounded replay and backpressure for large transfers. ([#6650](https://github.com/databricks/cli/pull/6650)) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| package client | ||
|
|
||
| import ( | ||
| "net/http" | ||
| "net/http/httptest" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/databricks/databricks-sdk-go" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| // The capabilities probe runs before the tunnel is dialled, on every proxy-mode connect. A driver | ||
| // proxy that accepts the request and never answers must not hold the connect path open: resume is | ||
| // an optimisation, and failing to learn about it has to cost bounded time, not the whole session. | ||
| func TestServerSupportsResumeGivesUpOnAStalledServer(t *testing.T) { | ||
| restore := capabilitiesProbeTimeout | ||
| capabilitiesProbeTimeout = time.Second | ||
| defer func() { capabilitiesProbeTimeout = restore }() | ||
|
|
||
| stalled := make(chan struct{}) | ||
|
|
||
| mux := http.NewServeMux() | ||
| mux.HandleFunc("GET /driver-proxy-api/o/123/cluster/7772/capabilities", func(w http.ResponseWriter, r *http.Request) { | ||
| <-stalled | ||
| }) | ||
| server := httptest.NewServer(mux) | ||
| // LIFO: release the stalled handler first, so Close does not wait on it. | ||
| defer server.Close() | ||
| defer close(stalled) | ||
|
|
||
| client, err := databricks.NewWorkspaceClient(&databricks.Config{Host: server.URL, Token: "test-token", WorkspaceID: "123", AuthType: "pat"}) | ||
| require.NoError(t, err) | ||
|
|
||
| done := make(chan bool, 1) | ||
| go func() { | ||
| done <- serverSupportsResume(t.Context(), client, "cluster", 7772, "") | ||
| }() | ||
|
|
||
| select { | ||
| case resumable := <-done: | ||
| require.False(t, resumable, "a server that never answered cannot be assumed to support resume") | ||
| case <-time.After(30 * time.Second): | ||
| t.Fatal("the capabilities probe never returned: it has no timeout of its own, so a stalled driver proxy blocks the connect path indefinitely") | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ import ( | |
| _ "embed" | ||
| "encoding/base64" | ||
| "encoding/hex" | ||
| "encoding/json" | ||
| "errors" | ||
| "fmt" | ||
| "io" | ||
|
|
@@ -493,7 +494,12 @@ func Run(ctx context.Context, client *databricks.WorkspaceClient, opts ClientOpt | |
| outcome.isSuccess = true | ||
|
|
||
| if opts.ProxyMode { | ||
| return runSSHProxy(ctx, client, serverPort, clusterID, opts) | ||
| proxyErr := runSSHProxy(ctx, client, serverPort, clusterID, opts) | ||
| // isSuccess stays true - the tunnel was established - so the category is what says | ||
| // whether the session ran to completion or was cut short, and why. Without it a | ||
| // mid-session drop is indistinguishable from a clean exit in telemetry. | ||
| outcome.errorCategory = proxySessionEndCategory(proxyErr) | ||
| return proxyErr | ||
| } else if opts.IDE != "" { | ||
| return runIDE(ctx, client, userName, keyPath, knownHostsPath, serverPort, clusterID, opts) | ||
| } else { | ||
|
|
@@ -973,13 +979,52 @@ func spawnSSHClient(ctx context.Context, client *databricks.WorkspaceClient, use | |
| } | ||
|
|
||
| func runSSHProxy(ctx context.Context, client *databricks.WorkspaceClient, serverPort int, clusterID string, opts ClientOptions) error { | ||
| createConn := func(ctx context.Context, connID string) (*websocket.Conn, error) { | ||
| return createWebsocketConnection(ctx, client, connID, clusterID, serverPort, opts.Liteswap) | ||
| resumable := serverSupportsResume(ctx, client, clusterID, serverPort, opts.Liteswap) | ||
| if !resumable { | ||
| log.Infof(ctx, "The SSH server does not support session resume, a dropped connection will end the session") | ||
| } | ||
| createConn := func(ctx context.Context, req proxy.DialRequest) (*websocket.Conn, error) { | ||
| req.ResumeCapable = resumable | ||
| return createWebsocketConnection(ctx, client, req, clusterID, serverPort, opts.Liteswap) | ||
| } | ||
| requestHandoverTick := func() <-chan time.Time { | ||
| return time.After(opts.HandoverTimeout) | ||
| } | ||
| return proxy.RunClientProxy(ctx, os.Stdin, os.Stdout, requestHandoverTick, opts.KeepaliveInterval, createConn) | ||
| return proxy.RunClientProxy(ctx, os.Stdin, os.Stdout, requestHandoverTick, opts.KeepaliveInterval, resumable, createConn) | ||
| } | ||
|
|
||
| // capabilitiesProbeTimeout caps the pre-connect capabilities probe. A var so tests can shorten it. | ||
| var capabilitiesProbeTimeout = 10 * time.Second | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How did you decide on this value? Can we make it smaller? 10s is a while to wait for a failed probe
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what do you mean by a failed probe here? |
||
|
|
||
| // serverSupportsResume reports whether the running SSH server speaks the resume protocol. | ||
| func serverSupportsResume(ctx context.Context, client *databricks.WorkspaceClient, clusterID string, serverPort int, liteswap string) bool { | ||
| req, err := newDriverProxyRequest(ctx, client, clusterID, serverPort, "capabilities", liteswap) | ||
| if err != nil { | ||
| log.Debugf(ctx, "Failed to build the server capabilities request: %v", err) | ||
| return false | ||
| } | ||
| // Bounded on its own: this probe runs before the tunnel is dialled, and resume is an | ||
| // optimisation, so a driver proxy that accepts the request and never answers must cost a | ||
| // bounded wait rather than the whole connect path. | ||
| httpClient := &http.Client{Transport: client.Config.HTTPTransport, Timeout: capabilitiesProbeTimeout} | ||
| resp, err := httpClient.Do(req) | ||
| if err != nil { | ||
| log.Debugf(ctx, "Failed to query the server capabilities: %v", err) | ||
| return false | ||
| } | ||
| defer resp.Body.Close() | ||
| if resp.StatusCode != http.StatusOK { | ||
| log.Debugf(ctx, "The server does not serve /capabilities (status %d)", resp.StatusCode) | ||
| return false | ||
| } | ||
| var capabilities struct { | ||
| ResumeVersion int `json:"resume_version"` | ||
| } | ||
| if err := json.NewDecoder(resp.Body).Decode(&capabilities); err != nil { | ||
| log.Debugf(ctx, "Failed to decode the server capabilities: %v", err) | ||
| return false | ||
| } | ||
| return capabilities.ResumeVersion == proxy.ResumeProtocolVersion | ||
| } | ||
|
|
||
| // accessModeUILabel maps a cluster's access mode to the name shown in the Databricks UI. | ||
|
|
@@ -1351,11 +1396,22 @@ func sshExtensionErrorCategory(err error) protos.SshTunnelErrorCategory { | |
| return protos.SshTunnelErrorCategoryUnknown | ||
| } | ||
|
|
||
| // category returns the error category to report. An interrupted attempt means the user gave | ||
| // up, whichever call happened to observe it first, so it wins over the category recorded at | ||
| // the failure site. An unattributed failure is reported as UNKNOWN so that it stays countable. | ||
| // category returns the error category to report. Once the tunnel is up nothing that follows is a | ||
| // connection failure, so an established session reports only how it ended, and only the proxy can | ||
| // say that. For a connection attempt, an interruption means the user gave up, whichever call | ||
| // happened to observe it first, so it wins over the category recorded at the failure site; an | ||
| // unattributed attempt is reported as UNKNOWN so that it stays countable. | ||
| func (o connectOutcome) category() protos.SshTunnelErrorCategory { | ||
| if o.isSuccess || o.err == nil { | ||
| if o.err == nil { | ||
| return protos.SshTunnelErrorCategoryUnspecified | ||
| } | ||
| if o.isSuccess { | ||
| // proxySessionEndCategory is the only thing that sets a category this late, so an empty | ||
| // one means the session simply ended: an interruption, an ordinary exit, or a non-zero | ||
| // exit from the ssh client or the user's own remote command. None is a tunnel failure. | ||
| if o.errorCategory != "" { | ||
| return o.errorCategory | ||
| } | ||
| return protos.SshTunnelErrorCategoryUnspecified | ||
| } | ||
| if errors.Is(o.ctxErr, context.Canceled) || errors.Is(o.err, context.Canceled) { | ||
|
|
@@ -1367,6 +1423,27 @@ func (o connectOutcome) category() protos.SshTunnelErrorCategory { | |
| return o.errorCategory | ||
| } | ||
|
|
||
| // proxySessionEndCategory attributes how a proxy-mode session ended. A dropped websocket is | ||
| // checked first on purpose: a drop that lands during a handover surfaces from either the | ||
| // receiving loop or the handover goroutine, whichever the errgroup records first, and it | ||
| // should be counted as a drop in both cases. HANDOVER_FAILED is then only the handover's own | ||
| // failures. An unrecognised error is left unattributed - normalizeProxyError already maps a | ||
| // clean finish and a user interrupt to nil. | ||
| func proxySessionEndCategory(err error) protos.SshTunnelErrorCategory { | ||
| switch { | ||
| case err == nil: | ||
| return "" | ||
| case errors.Is(err, proxy.ErrWebsocketDropped): | ||
| return protos.SshTunnelErrorCategoryWebsocketDropped | ||
| case errors.Is(err, proxy.ErrHandoverFailed): | ||
| return protos.SshTunnelErrorCategoryHandoverFailed | ||
| case errors.Is(err, proxy.ErrConnectFailed): | ||
| return protos.SshTunnelErrorCategoryWebsocketConnectFailed | ||
| default: | ||
| return "" | ||
| } | ||
| } | ||
|
|
||
| func logSshTunnelEvent(ctx context.Context, opts ClientOptions, outcome connectOutcome) { | ||
| telemetry.Log(ctx, protos.DatabricksCliLog{ | ||
| SshTunnelEvent: buildSshTunnelEvent(opts, outcome), | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why wait 30s if the timeout is 1s?