Skip to content

ssh: don't report a failed handover when the session ends mid-handover - #6976

Merged
rugpanov merged 9 commits into
mainfrom
ssh-handover-teardown-race
Oct 8, 2026
Merged

rugpanov merged 9 commits into
mainfrom
ssh-handover-teardown-race

Conversation

@rugpanov

@rugpanov rugpanov commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Why

TestQuickHandover failed in the merge queue: https://github.com/databricks/cli/actions/runs/37613006327/job/112764757499

The cause is a race at session end. A handover can wait for the old websocket to close when the session ends. Teardown then closes that websocket itself. The receiving loop sent the read error (use of closed network connection) to initiateHandover. The client handover goroutine then returned ErrHandoverFailed.

This also affects real sessions, not only the test:

  • A clean ssh exit during a handover reported ErrHandoverFailed, because no other error existed.
  • When the session ended with a real error, the handover error could reach the errgroup first. normalizeProxyError then changed it to nil, so the real error was lost.
  • Resumable sessions logged a false "recovering through session reattachment" line. A ready tick could then start a new handover for a session that was ending.

Changes

  • proxy.go: During a handover, the receiving loop now decides in this order:
    1. A normal close completes the swap, as before. This is also true when the session is ending, so teardown closes the replacement websocket gracefully and a resumable peer does not wait for a reattach.
    2. If the read fails after the proxy context is cancelled, teardown broke the read. The loop sends ctx.Err() to the handover initiator. Before, it sent the read error.
    3. Any other read error is a dropped websocket, as before.
  • client.go: If initiateHandover returns context.Canceled, the handover goroutine returns nil. The result of proxy.start then decides how the session ended. The check comes after the dial-failed branch, because a failed dial never changes the live connection. It comes before the resumable branch, so both modes stop the same way.
  • A real handover failure, for example a handover timeout (DeadlineExceeded) or a dropped websocket, still returns ErrHandoverFailed.

Tests

  • TestSessionEndDuringHandover: the handover dial goes to a server that never answers, so the handover stays in progress. Then the session ends. The test has 4 cases: a clean exit and a source error, in non-resumable and resumable mode. Each case checks the session result. It also checks that no new handover starts after the session ends.
    • Without the proxy.go change, all 4 cases failed in most of 10 runs.
    • Without the client.go change, the source-error and resumable cases failed in most of 10 runs.
  • TestServerEOFDuringHandoverIsACleanExit: sshd's stdout ends while the server reads the client's close acknowledgment for a handover. The client must exit cleanly.
  • TestClientEOFDuringHandoverReleasesServer: the client's input ends while the client holds the server's handover close. The server must end the session at once, and not wait for a reattach.
    • Both EOF tests failed in 20 of 20 runs on earlier versions of this PR, and passed with the base code. They run on all platforms.
  • TestHandoverDialCanceledKeepsHandingOver: a dial that fails with context.Canceled does not stop later handovers.
  • The new tests, TestQuickHandover, and TestHandoverDialFailureKeepsSessionAlive passed in 30 of 30 runs with -race. The experimental/ssh/internal/proxy, internal/client, and internal/server packages pass with -race.

The test client now uses a Done channel. With it, a test can wait for RunClientProxy to return before Cleanup closes the input pipe.

Known limits

These edge cases exist on main too, and this PR does not change them. Each needs a teardown during the short window of a handover, which runs once every 30 minutes:

  • If teardown breaks the read before the peer's handover close arrives, the initiator closes the replacement websocket without a close frame. A resumable peer then waits for a reattach.
  • During a handover, the receiving loop treats any normal close as the handover close, including the peer's "finished" close.

This pull request and its description were written by Isaac.

rugpanov and others added 4 commits October 7, 2026 14:26
Teardown closes the websocket while a handover may still be waiting for
the old connection to close. The receiving loop passed the resulting
"use of closed network connection" read error to initiateHandover, and
the client's handover goroutine returned ErrHandoverFailed. That error
could win the errgroup race against proxy.start's real outcome, so a
clean exit reported a handover failure. This is the TestQuickHandover
flake seen in the merge queue.

The receiving loop now passes the context cancellation on, and the
handover goroutine leaves the outcome to proxy.start when it sees it.

Co-authored-by: Isaac <no-reply@databricks.com>
…comes

Check context.Canceled only after the dial-failed branch, so a dial that
fails with Canceled while the session is live is retried on the next
tick instead of stopping all later handovers.

Make the regression test table-driven: a clean exit must report no
error, and a session that ends with a real source error during a
handover must report that error, not ErrHandoverFailed. The second case
covers the client.go change. The test client now exposes Done, so the
test can wait for the session to end before Cleanup closes the pipe.

Co-authored-by: Isaac <no-reply@databricks.com>
…on ends

Check context.Canceled before the resumable branch. A resumable session
that ended mid-handover logged a false "recovering through session
reattachment" line and went back into the loop, where a ready tick
could start another handover for a session being torn down.

Tests: run the session-end cases for resumable sessions too, and queue a
second tick to assert no handover starts after the session ended. Use
the existing blackHoleServer. Wait for each handover dial in
TestHandoverDialCanceledKeepsHandingOver so a write cannot overtake the
handover. The test client signals completion with one channel instead
of a channel plus a WaitGroup.

Co-authored-by: Isaac <no-reply@databricks.com>
Co-authored-by: Isaac <no-reply@databricks.com>
@eng-dev-ecosystem-bot

eng-dev-ecosystem-bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Integration test report

Commit: f7836a7

Run: 37743320059

Env ✅​pass 🙈​skip Time
✅​ aws linux 276 16 5:25
✅​ aws windows 278 14 3:27
✅​ azure linux 275 16 5:19
✅​ azure windows 277 14 3:16
✅​ gcp linux 276 16 5:17
✅​ gcp windows 278 14 3:21
Top 6 slowest tests (at least 2 minutes):
duration env testname
3:58 azure linux TestAccept
3:56 gcp linux TestAccept
3:53 aws linux TestAccept
3:26 aws windows TestAccept
3:19 gcp windows TestAccept
3:15 azure windows TestAccept

@rugpanov
rugpanov marked this pull request as ready for review October 7, 2026 13:33
@rugpanov
rugpanov requested review from a team as code owners October 7, 2026 13:33
@rugpanov
rugpanov requested a review from rclarey October 7, 2026 13:33
var closeConnSignal error
if !websocket.IsCloseError(err, websocket.CloseNormalClosure) {
switch {
case ctx.Err() != nil:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2 / merge blocker] Clean server exit during handover becomes a connection failure

This receiving loop also runs on the server. If sshd's stdout reaches EOF while the server processes the handover's normal close acknowledgment, this new branch replaces that acknowledgment with context.Canceled. acceptHandover then takes its error path and closes the replacement websocket without swapping it in or sending a graceful close. The subsequent closeProxyConnection still targets the old socket, so the client reports ErrWebsocketDropped instead of a clean exit.

Reproduction and results

The test below deterministically pauses the server's close handler after receiving the normal handover acknowledgment, produces EOF on the server's source, waits for teardown, and then releases the handler while the client's input remains open. It uses local WebSockets and pipes, not a real SSH server.

Save it as experimental/ssh/internal/proxy/review_server_eof_test.go and run:

go test -race ./experimental/ssh/internal/proxy -run '^TestReviewServerEOFDuringHandover$' -count=20
  • PR head 77ea838d70fe87000a9446a1eabeb8eccb77b5dd: 20/20 failures, returning proxy websocket dropped / websocket: close 1006 (abnormal closure): unexpected EOF.
  • Same regression test with client.go and proxy.go from base 5ed93138d9cb6a49776d2f4f0b668ad08a378be9 supplied through a Go overlay: 20/20 passes under -race.

Proposed fix

Restrict this cancellation override to the client/initiating side, preserving the server's handling of a normal close acknowledgment. The existing role distinction is whether pc.createWebsocketConnection is non-nil: clients dial; servers are constructed with a nil dialer. reattach already uses this distinction.

Concretely, change this case to:

case pc.createWebsocketConnection != nil && ctx.Err() != nil:

Keep the new client.go cancellation handling and the dial-failure precedence unchanged. Update the adjacent comment to explain that the override is client-side; a normal server acknowledgment must still complete the swap so cleanup can gracefully close the replacement connection. Do not simply prioritize normal closures over cancellation on both sides, since the client still needs the teardown handling this PR adds.

I tested this one-line guard through a Go overlay: the regression test plus TestSessionEndDuringHandover, TestHandoverDialCanceledKeepsHandingOver, TestQuickHandover, and TestHandoverDialFailureKeepsSessionAlive all passed 30 runs under -race. This is a tested proposal, not a change pushed to the PR branch.

Complete regression test
package proxy

import (
	"context"
	"io"
	"net/http"
	"net/http/httptest"
	"sync/atomic"
	"testing"
	"time"

	"github.com/gorilla/websocket"
	"github.com/stretchr/testify/require"
)

type reviewServerEOFSource struct {
	io.ReadCloser
	closed chan struct{}
}

func (source *reviewServerEOFSource) Close() error {
	err := source.ReadCloser.Close()
	close(source.closed)
	return err
}

func TestReviewServerEOFDuringHandover(t *testing.T) {
	ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
	defer cancel()
	serverProxy := newProxyConnection(nil)
	serverInput, serverWriter := io.Pipe()
	defer serverWriter.Close()
	source := &reviewServerEOFSource{ReadCloser: serverInput, closed: make(chan struct{})}
	serverReady := make(chan struct{})
	ackReceived := make(chan struct{})
	releaseAck := make(chan struct{})
	serverDone := make(chan error, 1)
	handoverDone := make(chan error, 1)
	var accepted atomic.Bool
	server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
		if !accepted.CompareAndSwap(false, true) {
			handoverDone <- serverProxy.acceptHandover(ctx, writer, request)
			return
		}
		if err := serverProxy.accept(writer, request); err != nil {
			serverDone <- err
			return
		}
		connection := serverProxy.conn.Load()
		defaultCloseHandler := connection.CloseHandler()
		connection.SetCloseHandler(func(code int, text string) error {
			err := defaultCloseHandler(code, text)
			close(ackReceived)
			select {
			case <-releaseAck:
			case <-ctx.Done():
			}
			return err
		})
		close(serverReady)
		err := serverProxy.start(ctx, source, io.Discard)
		closeProxyConnection(ctx, serverProxy)
		serverDone <- err
	}))
	defer server.Close()
	clientInput, clientWriter := io.Pipe()
	defer clientWriter.Close()
	ticks := make(chan time.Time, 1)
	clientDone := make(chan error, 1)
	go func() {
		clientDone <- RunClientProxy(ctx, clientInput, io.Discard, func() <-chan time.Time { return ticks }, time.Hour, false,
			func(dialCtx context.Context, _ DialRequest) (*websocket.Conn, error) {
				connection, response, err := websocket.DefaultDialer.DialContext(dialCtx, "ws"+server.URL[4:], nil)
				if response != nil {
					response.Body.Close()
				}
				return connection, err
			})
	}()
	select {
	case <-serverReady:
	case <-ctx.Done():
		t.Fatal("server did not accept the connection")
	}
	_, err := serverWriter.Write([]byte("SSH-2.0-review\r\n"))
	require.NoError(t, err)
	ticks <- time.Now()
	select {
	case <-ackReceived:
	case <-ctx.Done():
		t.Fatal("server did not receive the handover close acknowledgment")
	}
	require.NoError(t, serverWriter.Close())
	select {
	case <-source.closed:
	case <-ctx.Done():
		t.Fatal("server did not process EOF")
	}
	close(releaseAck)
	select {
	case err := <-handoverDone:
		t.Logf("server handover result: %v", err)
	case <-ctx.Done():
		t.Fatal("handover did not finish")
	}
	select {
	case err := <-serverDone:
		require.NoError(t, err)
	case <-ctx.Done():
		t.Fatal("server did not finish")
	}
	select {
	case err := <-clientDone:
		require.NoError(t, err, "a clean server EOF must remain a clean client exit during handover")
	case <-ctx.Done():
		t.Fatal("client did not finish")
	}
}

Posted with AI assistance.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed. Your repro failed 20/20 on 77ea838d7 and passed with base client.go/proxy.go; it's now TestServerEOFDuringHandoverIsACleanExit (moved to handover_teardown_test.go so it also runs on Windows).

The final fix differs from the client-only guard you proposed (that was 75dd6d500): in d43fa7f0d the receiving loop checks for a normal close first and completes the swap on both sides, as on main. Only a read that teardown broke passes ctx.Err() on. That covers this case, so the guard no longer changed behavior, and I dropped it.

Posted with AI assistance.

The receiving loop also runs on the server. When sshd's stdout ended
while the server read the client's normal close acknowledgment for a
handover, the new ctx.Err() case replaced that acknowledgment with
context.Canceled. acceptHandover then closed the replacement websocket
without swapping it in, and the client saw ErrWebsocketDropped instead
of a clean exit.

Apply the cancellation override on the client only, the side that
dials, as reattach already does. Add a regression test that holds the
server's close handler until its source has ended.

Co-authored-by: Isaac <no-reply@databricks.com>
@rugpanov
rugpanov requested a review from anton-107 October 7, 2026 14:41
Comment on lines +555 to +557
_, err := serverWriter.Write([]byte("SSH-2.0-test\r\n"))
require.NoError(t, err)
ticks <- time.Now()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2 / merge blocker] Wait for the client to receive the banner before starting handover

The production finding is correctly fixed by the client-only guard. There is still a synchronization gap in this test; it was also present in the reproducer from my earlier comment.

serverWriter.Write only waits for the pipe reader to consume the banner, not for runSendingLoop to send it or the client to receive it. The following interleaving is therefore possible:

  1. The server's sending loop consumes the banner, then is descheduled before sendMessage.
  2. This write returns and the test sends the handover tick. acceptHandover acquires the server's handover mutex first.
  3. The sending loop resumes and blocks trying to send the banner under that same mutex. It cannot proceed to read the subsequent EOF.
  4. The test waits for source.closed before closing releaseAck, but teardown needs that EOF, and the handover holds the mutex while waiting for the paused close acknowledgment.

This is a circular wait until the five-second context deadline, at which point the test fails with the server did not start its teardown even though the production fix is correct.

Reproduce

To force the scheduling window, temporarily add this method to the test's closeSignalingSource. The delay is diagnostic instrumentation, not a proposed fix:

func (source *closeSignalingSource) Read(buffer []byte) (int, error) {
    count, err := source.ReadCloser.Read(buffer)
    if count > 0 {
        time.Sleep(50 * time.Millisecond)
    }
    return count, err
}

Then run:

go test -race ./experimental/ssh/internal/proxy -run '^TestServerEOFDuringHandoverIsACleanExit$' -count=2 -v

On 75dd6d500139a2bef053fdd1a878254560d73331, this failed 2/2, each after five seconds. Ordinary unperturbed runs passed; this deliberately forces a valid interleaving that the test does not synchronize against.

Proposed fix

Capture client output with clientOutput := newTestBuffer(t) and pass it as the destination to RunClientProxy instead of io.Discard. Before sending the handover tick, wait until the client actually observes the banner:

banner := []byte("SSH-2.0-test\r\n")
_, err := serverWriter.Write(banner)
require.NoError(t, err)
require.NoError(t, clientOutput.WaitForWrite(banner))
ticks <- time.Now()

This establishes the intended precondition without sleeps. With that synchronization added, the test passed 30/30 under -race with the same 50 ms diagnostic delay still in place. Remove the diagnostic Read override from the final change.

Separately, the original regression, the added regression, and related handover tests passed 30 unperturbed race-enabled runs; the full proxy/client/server package suites passed too. Supplying the pre-fix proxy.go through a Go overlay makes the added regression fail 20/20, so its coverage of the original production bug is sound.

Posted with AI assistance.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Reproduced with your 50 ms read delay (3/3 failures), fixed in c7998d817 as you suggested: the client output goes to a testBuffer and the test waits for the banner before the tick. With the delay still in, it passed 30/30 under -race; the delay isn't in the commit.

Posted with AI assistance.

serverWriter.Write only proves that the server's sending loop read the
banner, not that it sent it. If acceptHandover took the handover mutex
first, the sending loop blocked on that mutex and never read the EOF,
while the handover waited for the held close acknowledgment. The test
then deadlocked until its 5s deadline.

Capture the client output and wait for the banner before the tick.

Co-authored-by: Isaac <no-reply@databricks.com>
@rugpanov
rugpanov requested a review from anton-107 October 7, 2026 16:26

@anton-107 anton-107 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please preserve prompt server cleanup when a clean client EOF races with an acknowledged handover. The inline comment includes a deterministic regression test (20/20 failures at this head; 20/20 passes with base production code). The demonstrated impact is bounded delayed cleanup of a resumable session, not data loss.

Posted with AI assistance.

var closeConnSignal error
if !websocket.IsCloseError(err, websocket.CloseNormalClosure) {
switch {
case pc.createWebsocketConnection != nil && ctx.Err() != nil:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve clean client shutdown after an acknowledged handover

This cancellation branch also overrides a normal handover close acknowledgment when client input reaches EOF. If the server has already swapped to the replacement websocket, signaling context.Canceled here sends initiateHandover through the error path at lines 698–701: it raw-closes newConn without installing it. Deferred client cleanup still targets the old websocket, so the replacement never receives the resumable protocol's "finished" close frame.

The client returns success, but a server whose source remains open treats this as a transient disconnect and enters awaitReattach, keeping the session alive for the default 90-second reattachment grace period instead of shutting down promptly. This is a regression in existing cleanup behavior; the demonstrated impact is delayed cleanup, not data loss or an interrupted active session.

Reproduction: pause the client's close handler after it sends the normal ACK; wait for the server to finish handover; close the client's input and wait for teardown to start; then release the close handler. Assert that successful client exit also ends the server session, rather than entering reattachment grace.

With the test below, run:

go test -race ./experimental/ssh/internal/proxy \
  -run '^TestReviewClientEOFDuringHandoverReleasesServer$' -count=20
  • PR head c7998d81780d2b61ceebb74b2718fa3d6660067f: 20/20 failures, reporting clean client EOF left the server waiting 1m30s for reattachment.
  • Identical test with only production client.go and proxy.go replaced through a Go build overlay by their versions at base 5ed93138d9cb6a49776d2f4f0b668ad08a378be9: 20/20 passes. The current test helpers are retained for this comparison.

Requested change: preserve explicit clean-session completion on the replacement websocket when EOF races with an acknowledged handover. Complete the acknowledged swap before graceful teardown, or otherwise ensure the replacement receives the "finished" notification rather than a raw close that looks recoverable to the server. Keep the cancellation/error-preservation fixes and the server-side fix, and add regression coverage asserting prompt server cleanup. This is a proposed fix direction, not a verified patch.

The test reuses newTestBuffer and closeSignalingSource already present in this PR's client_server_test.go. Its build constraint matches that helper file. Save it as experimental/ssh/internal/proxy/review_client_eof_test.go:

Deterministic regression test
//go:build !windows

package proxy

import (
	"context"
	"io"
	"net/http"
	"net/http/httptest"
	"sync/atomic"
	"testing"
	"time"

	"github.com/gorilla/websocket"
	"github.com/stretchr/testify/require"
)

func TestReviewClientEOFDuringHandoverReleasesServer(t *testing.T) {
	ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
	defer cancel()
	serverProxy := newResumableProxyConnection(nil)
	serverInput, serverWriter := io.Pipe()
	defer serverWriter.Close()
	serverReady := make(chan struct{})
	serverDone := make(chan error, 1)
	handoverDone := make(chan error, 1)
	var accepted atomic.Bool
	server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
		if !accepted.CompareAndSwap(false, true) {
			handoverDone <- serverProxy.acceptHandover(ctx, writer, request)
			return
		}
		if err := serverProxy.accept(writer, request); err != nil {
			serverDone <- err
			return
		}
		close(serverReady)
		err := serverProxy.start(ctx, serverInput, io.Discard)
		closeProxyConnection(ctx, serverProxy)
		serverDone <- err
	}))
	defer server.Close()
	clientInput, clientWriter := io.Pipe()
	defer clientWriter.Close()
	clientSource := &closeSignalingSource{ReadCloser: clientInput, closed: make(chan struct{})}
	clientOutput := newTestBuffer(t)
	ticks := make(chan time.Time, 1)
	clientDone := make(chan error, 1)
	ackSent := make(chan struct{})
	releaseAck := make(chan struct{})
	var dials atomic.Int32
	go func() {
		clientDone <- RunClientProxy(ctx, clientSource, clientOutput, func() <-chan time.Time { return ticks }, time.Hour, true,
			func(dialCtx context.Context, _ DialRequest) (*websocket.Conn, error) {
				connection, response, err := websocket.DefaultDialer.DialContext(dialCtx, "ws"+server.URL[4:], nil)
				if response != nil {
					response.Body.Close()
				}
				if err == nil && dials.Add(1) == 1 {
					defaultCloseHandler := connection.CloseHandler()
					connection.SetCloseHandler(func(code int, text string) error {
						err := defaultCloseHandler(code, text)
						close(ackSent)
						select {
						case <-releaseAck:
						case <-ctx.Done():
						}
						return err
					})
				}
				return connection, err
			})
	}()
	select {
	case <-serverReady:
	case <-ctx.Done():
		t.Fatal("server did not accept the connection")
	}
	banner := []byte("SSH-2.0-review\r\n")
	_, err := serverWriter.Write(banner)
	require.NoError(t, err)
	require.NoError(t, clientOutput.WaitForWrite(banner))
	ticks <- time.Now()
	select {
	case <-ackSent:
	case <-ctx.Done():
		t.Fatal("client did not acknowledge handover")
	}
	select {
	case err := <-handoverDone:
		require.NoError(t, err)
	case <-ctx.Done():
		t.Fatal("server did not finish the handover")
	}
	require.NoError(t, clientWriter.Close())
	select {
	case <-clientSource.closed:
	case <-ctx.Done():
		t.Fatal("client did not start its teardown")
	}
	close(releaseAck)
	select {
	case err := <-clientDone:
		require.NoError(t, err)
	case <-ctx.Done():
		t.Fatal("client did not finish")
	}
	select {
	case err := <-serverDone:
		require.NoError(t, err)
	case <-serverProxy.resume.parked:
		t.Fatalf("clean client EOF left the server waiting %v for reattachment", serverProxy.resume.grace)
	case <-ctx.Done():
		t.Fatal("server did not finish after clean client EOF")
	}
}

Posted with AI assistance.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in d43fa7f0d. Your exact test from this comment passes 20/20 under -race on the current head f7836a7f6 (it failed 20/20 on c7998d817). It's in the PR as TestClientEOFDuringHandoverReleasesServer, in handover_teardown_test.go so it also runs on Windows.

The fix follows your first direction: a normal close now completes the swap first, even when the session is ending, so RunClientProxy's deferred close sends "finished" on the replacement and the server ends at once. Only a read that teardown broke passes ctx.Err() on.

Two related orderings are not fixed here because main has them too: (1) teardown breaks the read before the handover close arrives, so the initiator raw-closes newConn; (2) during a handover a "finished" close is treated as the handover close. Both are under "Known limits" in the description. I'd handle them in a follow-up, e.g. by treating proxySessionFinished as session end in the handover branch. Happy to do that here instead if you'd rather.

Posted with AI assistance.

rugpanov and others added 2 commits October 8, 2026 09:07
If the client's input ended while its receiving loop held the server's
normal handover close, the ctx.Err() case replaced that close with
context.Canceled. initiateHandover then raw-closed the replacement
websocket without installing it, so the client's exit never sent the
resumable "finished" close there. The server took the raw close as a
drop and waited 90s for a reattach.

A normal close now always completes the swap, on both sides, as before
this PR. Only a read that teardown broke passes the cancellation on.
This also covers the server case the client-only guard handled, so the
guard is dropped.

Co-authored-by: Isaac <no-reply@databricks.com>
…dows

TestSessionEndDuringHandover dialed the cat-backed test server for the
initial connection. If that server's echo of the client's teardown
close arrived before teardown closed the socket, the read was a normal
close, the swap completed, and a queued tick could start a third dial.
Both connections now land on a server that drains raw bytes and never
answers, so the teardown read always fails locally.

Move TestServerEOFDuringHandoverIsACleanExit and
TestClientEOFDuringHandoverReleasesServer unchanged to a file without
the !windows build tag: they do not need the cat echo server.

State in the receiving loop comment that a normal close is usually,
not always, the peer's side of the handover.

Co-authored-by: Isaac <no-reply@databricks.com>
@rugpanov
rugpanov requested a review from anton-107 October 8, 2026 08:07

@anton-107 anton-107 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see #6976

@rugpanov
rugpanov requested a review from anton-107 October 8, 2026 09:43
@rugpanov
rugpanov added this pull request to the merge queue Oct 8, 2026
Merged via the queue into main with commit f7e98f7 Oct 8, 2026
22 checks passed
@rugpanov
rugpanov deleted the ssh-handover-teardown-race branch October 8, 2026 11:50
@eng-dev-ecosystem-bot

Copy link
Copy Markdown
Collaborator

Integration test report

Commit: f7e98f7

Run: 37772747693

Env ❌​FAIL 🔄​flaky ✅​pass 🙈​skip Time
❌​ aws linux-2core-8gb 31 1397 1132 135:36
❌​ aws-windows-latest-4core-16gb 34 1319 1157 150:29
🔄​ azure linux-2core-8gb 4 1273 1184 135:41
✅​ azure-windows-latest-4core-16gb 1202 1209 137:49
✅​ gcp linux-2core-8gb 1266 1188 131:21
🔄​ gcp-windows-latest-4core-16gb 2 1189 1213 138:20
42 interesting tests: 38 FAIL, 4 flaky
Test Name aws linux-2core-8gb aws-windows-latest-4core-16gb azure linux-2core-8gb gcp-windows-latest-4core-16gb
❌​ TestAccept ❌​F ❌​F ✅​p ✅​p
❌​ TestAccept/bundle/invariant/delete_idempotent ✅​p ❌​F ✅​p ✅​p
❌​ TestAccept/bundle/invariant/delete_idempotent/DMS=/INPUT_CONFIG=cluster.yml.tmpl/READPLAN= ✅​p ❌​F ✅​p ✅​p
❌​ TestAccept/bundle/invariant/delete_idempotent/DMS=/INPUT_CONFIG=cluster.yml.tmpl/READPLAN=1 ✅​p ❌​F ✅​p ✅​p
❌​ TestAccept/bundle/invariant/delete_idempotent/DMS=/INPUT_CONFIG=job_pydabs_1000_tasks.yml.tmpl/READPLAN= ✅​p ❌​F ✅​p ✅​p
❌​ TestAccept/bundle/invariant/delete_idempotent/DMS=/INPUT_CONFIG=job_pydabs_10_tasks.yml.tmpl/READPLAN=1 ✅​p ❌​F ✅​p ✅​p
🔄​ TestAccept/bundle/invariant/destroy_idempotent/DMS=/INPUT_CONFIG=cluster.yml.tmpl/READPLAN=1 ✅​p ✅​p ✅​p 🔄​f
🔄​ TestAccept/bundle/invariant/destroy_idempotent/DMS=/INPUT_CONFIG=cluster_apply_policy_default_values.yml.tmpl/READPLAN= ✅​p ✅​p ✅​p 🔄​f
❌​ TestAccept/bundle/invariant/no_drift ❌​F ✅​p ✅​p ✅​p
❌​ TestAccept/bundle/invariant/no_drift/DMS=/INPUT_CONFIG=genie_space.yml.tmpl/READPLAN= ❌​F ✅​p ✅​p ✅​p
❌​ TestAccept/bundle/resources/apps/lifecycle-started ✅​p ❌​F 🔄​f ✅​p
🔄​ TestAccept/bundle/resources/apps/lifecycle-started-toggle ✅​p ✅​p 🔄​f ✅​p
🔄​ TestAccept/bundle/resources/apps/lifecycle-started-toggle/DMS=true ✅​p ✅​p 🔄​f ✅​p
❌​ TestAccept/bundle/resources/apps/lifecycle-started/DMS=true ✅​p ❌​F 🔄​f ✅​p
❌​ TestAccept/bundle/resources/postgres_projects/add_default_endpoint_settings ❌​F ❌​F 🙈​s 🙈​s
❌​ TestAccept/bundle/resources/postgres_projects/add_default_endpoint_settings/DMS= ❌​F ❌​F
❌​ TestAccept/bundle/resources/postgres_projects/add_default_endpoint_settings/DMS=true ❌​F ❌​F
❌​ TestAccept/bundle/resources/postgres_projects/basic ❌​F ❌​F 🙈​s 🙈​s
❌​ TestAccept/bundle/resources/postgres_projects/basic/DMS= ❌​F ❌​F
❌​ TestAccept/bundle/resources/postgres_projects/basic/DMS=true ❌​F ❌​F
❌​ TestAccept/bundle/resources/postgres_projects/factcheck/update_mask_spec ❌​F ❌​F 🙈​s 🙈​s
❌​ TestAccept/bundle/resources/postgres_projects/factcheck/update_mask_star ❌​F ❌​F 🙈​s 🙈​s
❌​ TestAccept/bundle/resources/postgres_projects/purge_on_delete ❌​F ❌​F 🙈​s 🙈​s
❌​ TestAccept/bundle/resources/postgres_projects/purge_on_delete/DMS= ❌​F ❌​F
❌​ TestAccept/bundle/resources/postgres_projects/purge_on_delete/DMS=true ❌​F ❌​F
❌​ TestAccept/bundle/resources/postgres_projects/recreate ❌​F ❌​F 🙈​s 🙈​s
❌​ TestAccept/bundle/resources/postgres_projects/recreate/DMS= ❌​F ❌​F
❌​ TestAccept/bundle/resources/postgres_projects/recreate/DMS=true ❌​F ❌​F
❌​ TestAccept/bundle/resources/postgres_projects/remove_history_retention ❌​F ❌​F 🙈​s 🙈​s
❌​ TestAccept/bundle/resources/postgres_projects/remove_history_retention/DMS= ❌​F ❌​F
❌​ TestAccept/bundle/resources/postgres_projects/remove_history_retention/DMS=true ❌​F ❌​F
❌​ TestAccept/bundle/resources/postgres_projects/update_default_endpoint_suspend ❌​F ❌​F 🙈​s 🙈​s
❌​ TestAccept/bundle/resources/postgres_projects/update_default_endpoint_suspend/DMS= ❌​F ❌​F
❌​ TestAccept/bundle/resources/postgres_projects/update_default_endpoint_suspend/DMS=true ❌​F ❌​F
❌​ TestAccept/bundle/resources/postgres_projects/update_display_name ❌​F ❌​F 🙈​s 🙈​s
❌​ TestAccept/bundle/resources/postgres_projects/update_display_name/DMS= ❌​F ❌​F
❌​ TestAccept/bundle/resources/postgres_projects/update_display_name/DMS=true ❌​F ❌​F
❌​ TestAccept/bundle/resources/postgres_projects/update_history_retention ❌​F ❌​F 🙈​s 🙈​s
❌​ TestAccept/bundle/resources/postgres_projects/update_history_retention/DMS= ❌​F ❌​F
❌​ TestAccept/bundle/resources/postgres_projects/update_history_retention/DMS=true ❌​F ❌​F
❌​ TestAccept/bundle/resources/registered_models/basic ❌​F ✅​p ✅​p ✅​p
❌​ TestAccept/bundle/resources/registered_models/basic/DMS=true ❌​F ✅​p ✅​p ✅​p
Top 50 slowest tests (at least 2 minutes):
duration env testname
13:46 gcp-windows-latest-4core-16gb TestAccept/bundle/resources/apps/lifecycle-started/DMS=
12:38 gcp linux-2core-8gb TestAccept/bundle/resources/clusters/lifecycle-started/DMS=
12:08 gcp-windows-latest-4core-16gb TestAccept/bundle/resources/apps/lifecycle-started/DMS=true
12:03 gcp linux-2core-8gb TestAccept/bundle/resources/apps/lifecycle-started/DMS=true
11:08 azure linux-2core-8gb TestAccept/bundle/invariant/destroy_idempotent/DMS=/INPUT_CONFIG=cluster_apply_policy_default_values.yml.tmpl/READPLAN=1
10:36 azure linux-2core-8gb TestAccept/bundle/invariant/destroy_idempotent/DMS=/INPUT_CONFIG=cluster_libraries.yml.tmpl/READPLAN=
10:09 azure-windows-latest-4core-16gb TestAccept/bundle/invariant/no_drift/DMS=/INPUT_CONFIG=cluster_apply_policy_default_values.yml.tmpl/READPLAN=1
9:31 azure-windows-latest-4core-16gb TestAccept/bundle/resources/clusters/lifecycle-started/DMS=
9:21 gcp linux-2core-8gb TestAccept/bundle/resources/apps/lifecycle-started/DMS=
8:53 gcp-windows-latest-4core-16gb TestAccept/bundle/resources/clusters/deploy/local_ssd_count/DMS=
8:21 gcp-windows-latest-4core-16gb TestAccept/bundle/resources/clusters/deploy/local_ssd_count/DMS=true
8:07 gcp linux-2core-8gb TestAccept/bundle/resources/clusters/lifecycle-started/DMS=true
7:57 azure-windows-latest-4core-16gb TestAccept/bundle/resources/apps/lifecycle-started/DMS=true
7:56 aws linux-2core-8gb TestAccept/bundle/resources/apps/lifecycle-started/DMS=
7:55 aws-windows-latest-4core-16gb TestAccept/bundle/resources/apps/lifecycle-started/DMS=
7:54 azure-windows-latest-4core-16gb TestAccept/bundle/resources/apps/lifecycle-started/DMS=
7:45 azure-windows-latest-4core-16gb TestAccept/bundle/invariant/no_drift/DMS=/INPUT_CONFIG=cluster_libraries.yml.tmpl/READPLAN=1
7:30 aws-windows-latest-4core-16gb TestAccept/bundle/resources/clusters/lifecycle-started/DMS=true
7:30 azure-windows-latest-4core-16gb TestAccept/bundle/invariant/no_drift/DMS=/INPUT_CONFIG=cluster_apply_policy_default_values.yml.tmpl/READPLAN=
7:25 gcp linux-2core-8gb TestAccept/bundle/resources/clusters/deploy/local_ssd_count/DMS=
7:24 gcp-windows-latest-4core-16gb TestAccept/bundle/resources/clusters/lifecycle-started/DMS=true
7:22 aws linux-2core-8gb TestAccept/bundle/resources/apps/lifecycle-started/DMS=true
7:21 azure linux-2core-8gb TestAccept/bundle/resources/apps/lifecycle-started/DMS=
7:11 azure linux-2core-8gb TestAccept/bundle/resources/apps/lifecycle-started/DMS=true
7:08 gcp linux-2core-8gb TestAccept/bundle/resources/clusters/deploy/local_ssd_count/DMS=true
7:00 aws linux-2core-8gb TestAccept/bundle/config-remote-sync/multiple_resources/DMS=
6:57 aws linux-2core-8gb TestAccept/bundle/resources/clusters/lifecycle-started/DMS=
6:55 gcp linux-2core-8gb TestAccept/bundle/config-remote-sync/multiple_resources/DMS=true
6:46 gcp linux-2core-8gb TestAccept/bundle/resources/clusters/deploy/update-after-create/DMS=
6:46 azure linux-2core-8gb TestAccept/bundle/config-remote-sync/multiple_resources/DMS=
6:39 gcp-windows-latest-4core-16gb TestAccept/bundle/resources/clusters/lifecycle-started/DMS=
6:37 gcp linux-2core-8gb TestAccept/bundle/config-remote-sync/multiple_resources/DMS=
6:33 aws linux-2core-8gb TestAccept/bundle/config-remote-sync/multiple_resources/DMS=true
6:20 azure linux-2core-8gb TestAccept/bundle/invariant/destroy_idempotent/DMS=/INPUT_CONFIG=cluster_apply_policy_default_values.yml.tmpl/READPLAN=
6:13 azure linux-2core-8gb TestAccept/bundle/config-remote-sync/multiple_resources/DMS=true
6:08 azure-windows-latest-4core-16gb TestAccept/bundle/resources/clusters/lifecycle-started/DMS=true
6:05 aws-windows-latest-4core-16gb TestAccept/bundle/resources/clusters/lifecycle-started/DMS=
5:51 azure linux-2core-8gb TestAccept/bundle/resources/clusters/lifecycle-started/DMS=
5:49 aws linux-2core-8gb TestAccept/bundle/resources/clusters/lifecycle-started/DMS=true
5:34 azure-windows-latest-4core-16gb TestAccept/bundle/resources/clusters/lifecycle-started-toggle/DMS=
5:32 azure-windows-latest-4core-16gb TestAccept/bundle/resources/clusters/lifecycle-started-toggle/DMS=true
5:30 azure linux-2core-8gb TestAccept/bundle/resources/clusters/lifecycle-started-toggle/DMS=
5:22 gcp-windows-latest-4core-16gb TestAccept/bundle/resources/apps/lifecycle-started-omitted/DMS=true
5:21 azure linux-2core-8gb TestAccept/bundle/resources/clusters/lifecycle-started/DMS=true
5:19 gcp-windows-latest-4core-16gb TestAccept/bundle/resources/clusters/lifecycle-started-toggle/DMS=
5:17 gcp linux-2core-8gb TestAccept/bundle/resources/clusters/lifecycle-started-toggle/DMS=
5:10 aws linux-2core-8gb TestAccept/bundle/deploy/spark-jar-task/DMS=
5:10 azure linux-2core-8gb TestAccept/bundle/deploy/spark-jar-task/DMS=
5:06 gcp linux-2core-8gb TestAccept/bundle/deploy/spark-jar-task/DMS=true
4:57 aws linux-2core-8gb TestAccept/bundle/deploy/spark-jar-task/DMS=true

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants