From cdaf738575254cf2551ade2c5ad0a5082d62f9d7 Mon Sep 17 00:00:00 2001 From: Eric Zhang Date: Fri, 11 Sep 2026 17:56:58 +0000 Subject: [PATCH] fix(coderd): reject redundant SSH autostarts of running workspaces --- coderd/workspacebuilds_ssh_test.go | 66 ++++++++++++++++++++++++++++++ coderd/wsbuilder/wsbuilder.go | 13 ++++++ 2 files changed, 79 insertions(+) create mode 100644 coderd/workspacebuilds_ssh_test.go diff --git a/coderd/workspacebuilds_ssh_test.go b/coderd/workspacebuilds_ssh_test.go new file mode 100644 index 00000000000..c474dfa6d63 --- /dev/null +++ b/coderd/workspacebuilds_ssh_test.go @@ -0,0 +1,66 @@ +package coderd_test + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +func TestWorkspaceBuildSSHAutostart(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + stopped bool + reason codersdk.CreateWorkspaceBuildReason + conflict bool + }{ + {name: "Running", reason: codersdk.CreateWorkspaceBuildReasonSSHConnection, conflict: true}, + {name: "Stopped", stopped: true, reason: codersdk.CreateWorkspaceBuildReasonSSHConnection}, + {name: "ExplicitStart", reason: codersdk.CreateWorkspaceBuildReasonDashboard}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitLong) + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, template.ID) + latest := coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + if tc.stopped { + stop, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + Transition: codersdk.WorkspaceTransitionStop, + }) + require.NoError(t, err) + latest = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, stop.ID) + } + + // An SSH client may submit a delayed autostart after another + // client has already finished starting the workspace. + build, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + Transition: codersdk.WorkspaceTransitionStart, + Reason: tc.reason, + }) + if tc.conflict { + var apiError *codersdk.Error + require.ErrorAs(t, err, &apiError) + require.Equal(t, http.StatusConflict, apiError.StatusCode()) + current, err := client.Workspace(ctx, workspace.ID) + require.NoError(t, err) + require.Equal(t, latest.ID, current.LatestBuild.ID) + return + } + require.NoError(t, err) + require.Equal(t, latest.BuildNumber+1, build.BuildNumber) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID) + }) + } +} diff --git a/coderd/wsbuilder/wsbuilder.go b/coderd/wsbuilder/wsbuilder.go index 45071f0ccbe..ddf2cbd997e 100644 --- a/coderd/wsbuilder/wsbuilder.go +++ b/coderd/wsbuilder/wsbuilder.go @@ -1386,6 +1386,19 @@ func (b *Builder) checkRunningBuild() error { xerrors.New(msg), } } + if b.trans == database.WorkspaceTransitionStart && b.reason == database.BuildReasonSshConnection && job.JobStatus == database.ProvisionerJobStatusSucceeded { + build, err := b.getLastBuild() + if err != nil { + return BuildError{http.StatusInternalServerError, "failed to fetch prior build", err} + } + if build.Transition == database.WorkspaceTransitionStart { + // An SSH client can read a stopped workspace before another client + // starts it, then submit its request after that build completes. + // Preserve the running build and let the client reconnect on 409. + msg := "The workspace is already running." + return BuildError{http.StatusConflict, msg, xerrors.New(msg)} + } + } return nil }