Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions agent/agentproc/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/coder/coder/v2/agent/agentchat"
"github.com/coder/coder/v2/agent/agentexec"
"github.com/coder/coder/v2/agent/agentgit"
"github.com/coder/coder/v2/agent/agentrunonce"
"github.com/coder/coder/v2/agent/usershell"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/codersdk"
Expand Down Expand Up @@ -87,8 +88,30 @@ func (api *API) handleStartProcess(rw http.ResponseWriter, r *http.Request) {
chatID = chatContext.ID.String()
}

proc, err := api.manager.start(req, chatID)
proc, attached, err := api.manager.start(ctx, req, chatID)
if err != nil {
if errors.Is(err, agentrunonce.ErrInputMismatch) {
httpapi.Write(ctx, rw, http.StatusConflict, workspacesdk.ProcessConflictError{
Code: workspacesdk.ProcessConflictInputMismatch,
Response: codersdk.Response{
Message: "Idempotency key was already used to start a process with different parameters.",
Detail: err.Error(),
},
})
return
}
if errors.Is(err, agentrunonce.ErrPublicationPending) {
// The concurrent start may still publish a process under
// this key; 409 keeps the dispatch recoverable.
httpapi.Write(ctx, rw, http.StatusConflict, workspacesdk.ProcessConflictError{
Code: workspacesdk.ProcessConflictStartPending,
Response: codersdk.Response{
Message: "Timed out waiting for the concurrent start that holds this idempotency key.",
Detail: err.Error(),
},
})
return
}
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to start process.",
Detail: err.Error(),
Expand All @@ -99,7 +122,9 @@ func (api *API) handleStartProcess(rw http.ResponseWriter, r *http.Request) {
// Notify git watchers after the process finishes so that
// file changes made by the command are visible in the scan.
// If a workdir is provided, track it as a path as well.
if api.pathStore != nil {
// An attached process already has a watcher from the request
// that started it.
if api.pathStore != nil && !attached {
if chatContext, ok := agentchat.FromContext(ctx); ok {
allIDs := append([]uuid.UUID{chatContext.ID}, chatContext.AncestorIDs...)
go func() {
Expand All @@ -114,8 +139,11 @@ func (api *API) handleStartProcess(rw http.ResponseWriter, r *http.Request) {
}

httpapi.Write(ctx, rw, http.StatusOK, workspacesdk.StartProcessResponse{
ID: proc.id,
Started: true,
ID: proc.id,
Started: !attached,
IdempotencyKey: req.IdempotencyKey,
Attached: attached,
StartedAt: proc.info().StartedAt,
})
}

Expand Down
199 changes: 199 additions & 0 deletions agent/agentproc/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,205 @@ func TestStartProcess(t *testing.T) {
})
}

// requireConflictCode keeps conflict assertions off the error text.
func requireConflictCode(t *testing.T, w *httptest.ResponseRecorder, want workspacesdk.ProcessConflictCode) {
t.Helper()

require.Equal(t, http.StatusConflict, w.Code)
var conflict workspacesdk.ProcessConflictError
require.NoError(t, json.NewDecoder(w.Body).Decode(&conflict))
require.Equal(t, want, conflict.Code)
require.NotEmpty(t, conflict.Message)
}

func TestStartProcessIdempotencyKey(t *testing.T) {
t.Parallel()

t.Run("SameKeyAttaches", func(t *testing.T) {
t.Parallel()

handler := newTestAPI(t)
req := workspacesdk.StartProcessRequest{
Command: "echo hello",
IdempotencyKey: "key-1",
}

w := postStart(t, handler, req)
require.Equal(t, http.StatusOK, w.Code)
var first workspacesdk.StartProcessResponse
require.NoError(t, json.NewDecoder(w.Body).Decode(&first))
require.True(t, first.Started)
require.False(t, first.Attached)
require.Equal(t, "key-1", first.IdempotencyKey)
require.NotZero(t, first.StartedAt)

w2 := postStart(t, handler, req)
require.Equal(t, http.StatusOK, w2.Code)
var second workspacesdk.StartProcessResponse
require.NoError(t, json.NewDecoder(w2.Body).Decode(&second))
require.Equal(t, first.ID, second.ID)
require.False(t, second.Started)
require.True(t, second.Attached)
require.Equal(t, "key-1", second.IdempotencyKey)
require.Equal(t, first.StartedAt, second.StartedAt)
})

t.Run("SameKeyDifferentCommandConflicts", func(t *testing.T) {
t.Parallel()

handler := newTestAPI(t)
w := postStart(t, handler, workspacesdk.StartProcessRequest{
Command: "echo hello",
IdempotencyKey: "key-1",
})
require.Equal(t, http.StatusOK, w.Code)

w2 := postStart(t, handler, workspacesdk.StartProcessRequest{
Command: "echo goodbye",
IdempotencyKey: "key-1",
})
requireConflictCode(t, w2, workspacesdk.ProcessConflictInputMismatch)
})

t.Run("SameKeyWaiterTimeoutConflicts", func(t *testing.T) {
t.Parallel()

release := make(chan struct{})
releaseOnce := sync.OnceFunc(func() { close(release) })
t.Cleanup(releaseOnce)
firstStalled := make(chan struct{})
firstStalledOnce := sync.OnceFunc(func() { close(firstStalled) })
// Block the owning start after it reserves the token;
// updateEnv runs only for spawning starts, so it also
// signals that the reservation is held.
handler := newTestAPIWithUpdateEnv(t, func(current []string) ([]string, error) {
firstStalledOnce()
<-release
return current, nil
})
req := workspacesdk.StartProcessRequest{
Command: "echo hello",
IdempotencyKey: "key-wait",
}

firstDone := make(chan *httptest.ResponseRecorder, 1)
go func() {
body, err := json.Marshal(req)
assert.NoError(t, err)
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodPost, "/start", bytes.NewReader(body))
handler.ServeHTTP(w, r)
firstDone <- w
}()
select {
case <-firstStalled:
case <-time.After(testutil.WaitShort):
t.Fatal("first start never reserved the key")
}

// The waiter's request context expires while the owner is
// blocked; the recoverable 409 is required, not a 500.
body, err := json.Marshal(req)
require.NoError(t, err)
waitCtx, cancelWait := context.WithCancel(context.Background())
cancelWait()
w := httptest.NewRecorder()
r := httptest.NewRequestWithContext(waitCtx, http.MethodPost, "/start", bytes.NewReader(body))
handler.ServeHTTP(w, r)
requireConflictCode(t, w, workspacesdk.ProcessConflictStartPending)

releaseOnce()
select {
case w := <-firstDone:
require.Equal(t, http.StatusOK, w.Code)
case <-time.After(testutil.WaitLong):
t.Fatal("first start request did not return")
}
})

t.Run("SameKeyDifferentBackgroundConflicts", func(t *testing.T) {
t.Parallel()

handler := newTestAPI(t)
w := postStart(t, handler, workspacesdk.StartProcessRequest{
Command: "echo hello",
IdempotencyKey: "key-1",
})
require.Equal(t, http.StatusOK, w.Code)

w2 := postStart(t, handler, workspacesdk.StartProcessRequest{
Command: "echo hello",
Background: true,
IdempotencyKey: "key-1",
})
requireConflictCode(t, w2, workspacesdk.ProcessConflictInputMismatch)
})

t.Run("SameKeyDifferentEnvConflicts", func(t *testing.T) {
t.Parallel()

handler := newTestAPI(t)
w := postStart(t, handler, workspacesdk.StartProcessRequest{
Command: "echo hello",
Env: map[string]string{"FOO": "bar"},
IdempotencyKey: "key-1",
})
require.Equal(t, http.StatusOK, w.Code)

w2 := postStart(t, handler, workspacesdk.StartProcessRequest{
Command: "echo hello",
Env: map[string]string{"FOO": "baz"},
IdempotencyKey: "key-1",
})
requireConflictCode(t, w2, workspacesdk.ProcessConflictInputMismatch)
})

t.Run("SameKeyDifferentChatsStartIndependently", func(t *testing.T) {
t.Parallel()

// Reservations are scoped to the chat that supplied the key,
// so one chat reusing another's key value starts its own
// process instead of attaching or conflicting.
handler := newTestAPI(t)
req := workspacesdk.StartProcessRequest{
Command: "echo hello",
IdempotencyKey: "key-1",
}
headerA := http.Header{}
headerA.Set(workspacesdk.CoderChatIDHeader, uuid.New().String())
headerB := http.Header{}
headerB.Set(workspacesdk.CoderChatIDHeader, uuid.New().String())

idA := startAndGetID(t, handler, req, headerA)
idB := startAndGetID(t, handler, req, headerB)
require.NotEqual(t, idA, idB)
})

t.Run("NoKeyAlwaysStartsNew", func(t *testing.T) {
t.Parallel()

handler := newTestAPI(t)
req := workspacesdk.StartProcessRequest{
Command: "echo hello",
}

w := postStart(t, handler, req)
require.Equal(t, http.StatusOK, w.Code)
var first workspacesdk.StartProcessResponse
require.NoError(t, json.NewDecoder(w.Body).Decode(&first))
require.True(t, first.Started)
require.False(t, first.Attached)
require.Empty(t, first.IdempotencyKey)

w2 := postStart(t, handler, req)
require.Equal(t, http.StatusOK, w2.Code)
var second workspacesdk.StartProcessResponse
require.NoError(t, json.NewDecoder(w2.Body).Decode(&second))
require.True(t, second.Started)
require.NotEqual(t, first.ID, second.ID)
})
}

func TestListProcesses(t *testing.T) {
t.Parallel()

Expand Down
Loading
Loading