Skip to content
Open
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
58 changes: 54 additions & 4 deletions server/cmd/api/api/browser_repl.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ const (
// and metadata, so 48 MiB leaves ample headroom while still bounding
// memory on protocol corruption.
browserReplMaxResponseBytes = 48 << 20

// maxBrowserReplIdempotentResults caps the responses kept for retries.
// Executions are serialized, so a retry targets one of the latest, and a
// response can be up to browserReplMaxResponseBytes.
maxBrowserReplIdempotentResults = 4
// maxBrowserReplIdempotentPending caps the keyed executions waiting or
// running, each holding a request of up to maxBrowserReplBodyBytes.
maxBrowserReplIdempotentPending = 8
)

// browserReplChild tracks the owned REPL child process. The API process is
Expand All @@ -81,16 +89,19 @@ type browserReplManager struct {
child *browserReplChild // guarded by admission
customToolsMu sync.RWMutex
customToolsReplID string

idempotent *idempotentCalls[oapi.ExecuteBrowserReplResponseObject]
}

func newBrowserReplManager() *browserReplManager {
lifecycle, stop := context.WithCancelCause(context.Background())
admission := make(chan struct{}, 1)
admission <- struct{}{}
return &browserReplManager{
admission: admission,
lifecycle: lifecycle,
stop: stop,
admission: admission,
lifecycle: lifecycle,
stop: stop,
idempotent: newIdempotentCalls[oapi.ExecuteBrowserReplResponseObject](maxBrowserReplIdempotentResults, maxBrowserReplIdempotentPending),
}
}

Expand Down Expand Up @@ -636,7 +647,46 @@ func StrictBrowserReplBodyMiddleware(next http.Handler) http.Handler {

// ExecuteBrowserRepl implements POST /repl through the REPL subsystem.
func (s *ApiService) ExecuteBrowserRepl(ctx context.Context, request oapi.ExecuteBrowserReplRequestObject) (oapi.ExecuteBrowserReplResponseObject, error) {
return s.browserRepl.Execute(ctx, request)
if request.Params.IdempotencyKey == nil || request.Body == nil {
return s.browserRepl.Execute(ctx, request)
}

key := *request.Params.IdempotencyKey
if err := validateIdempotencyKey(key); err != nil {
return oapi.ExecuteBrowserRepl400JSONResponse{
BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: err.Error()},
}, nil
}
timeoutSec := 60
if request.Body.TimeoutSec != nil {
timeoutSec = *request.Body.TimeoutSec
}
fingerprint := idempotencyFingerprint(struct {
Code string
Reset bool
TimeoutSec int
}{request.Body.Code, request.Body.Reset != nil && *request.Body.Reset, timeoutSec})
resp, err := s.browserRepl.idempotent.Do(ctx, key, fingerprint,
func(ctx context.Context) (oapi.ExecuteBrowserReplResponseObject, error) {
defer s.holdScaleToZero(ctx)()
return s.browserRepl.Execute(ctx, request)
},
func(resp oapi.ExecuteBrowserReplResponseObject) bool {
// Every other response is returned before the code reaches the REPL.
_, ran := resp.(oapi.ExecuteBrowserRepl200JSONResponse)
return ran
})
switch {
case errors.Is(err, errIdempotencyKeyReused):
return oapi.ExecuteBrowserRepl422JSONResponse{
IdempotencyKeyReusedErrorJSONResponse: oapi.IdempotencyKeyReusedErrorJSONResponse{Message: err.Error()},
}, nil
case errors.Is(err, errIdempotentCallsBusy):
return oapi.ExecuteBrowserRepl429JSONResponse{
IdempotentCallsBusyErrorJSONResponse: oapi.IdempotentCallsBusyErrorJSONResponse{Message: err.Error()},
}, nil
}
return resp, err
}

func (m *browserReplManager) Execute(ctx context.Context, request oapi.ExecuteBrowserReplRequestObject) (oapi.ExecuteBrowserReplResponseObject, error) {
Expand Down
68 changes: 68 additions & 0 deletions server/cmd/api/api/browser_repl_lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1024,3 +1024,71 @@ func (f *fakeCDPServer) evalExpression(expr string, sessionID string) any {
return expr
}
}

func TestBrowserReplIdempotencyKey(t *testing.T) {
svc := newBrowserReplSvc(t)
initial := requireExec(t, svc, `var keyedRuns = 0`, nil)

execute := func(ctx context.Context, key, code string) (oapi.ExecuteBrowserReplResponseObject, error) {
return svc.ExecuteBrowserRepl(ctx, oapi.ExecuteBrowserReplRequestObject{
Params: oapi.ExecuteBrowserReplParams{IdempotencyKey: &key},
Body: &oapi.ExecuteBrowserReplJSONRequestBody{Code: code},
})
}
const code = `keyedRuns++; await new Promise(resolve => setTimeout(resolve, 300)); repl.write(JSON.stringify(keyedRuns))`

// Without a key, a disconnect mid-execution destroys the REPL. With one,
// the execution finishes and the retry gets its result.
ctx, cancel := context.WithCancel(context.Background())
firstDone := make(chan error, 1)
go func() {
_, err := execute(ctx, "k1", code)
firstDone <- err
}()
require.Eventually(t, func() bool { return browserReplBusy(svc) }, time.Second, 10*time.Millisecond)
cancel()
require.ErrorIs(t, <-firstDone, context.Canceled)

resp, err := execute(context.Background(), "k1", code)
require.NoError(t, err)
retried, ok := resp.(oapi.ExecuteBrowserRepl200JSONResponse)
require.True(t, ok, "expected 200, got %T", resp)
require.True(t, retried.Success, "execution failed: %v", retried.Error)
assert.Nil(t, retried.ReplTerminated)
assert.Equal(t, initial.ReplId, retried.ReplId)
requireExec(t, svc, `repl.write(JSON.stringify(keyedRuns))`, float64(1))

resp, err = execute(context.Background(), "k1", `keyedRuns++`)
require.NoError(t, err)
assert.IsType(t, oapi.ExecuteBrowserRepl422JSONResponse{}, resp)
requireExec(t, svc, `repl.write(JSON.stringify(keyedRuns))`, float64(1))
}

// Aborting a hung keyed cell frees the REPL once the abandon grace period
// passes, instead of after the cell's timeout.
func TestBrowserReplAbandonedKeyedCellIsTerminated(t *testing.T) {
svc := newBrowserReplSvc(t)
svc.browserRepl.idempotent.abandonGrace = 200 * time.Millisecond
initial := requireExec(t, svc, `var before = 1`, nil)

key := "hung"
timeoutSec := 300
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
_, err := svc.ExecuteBrowserRepl(ctx, oapi.ExecuteBrowserReplRequestObject{
Params: oapi.ExecuteBrowserReplParams{IdempotencyKey: &key},
Body: &oapi.ExecuteBrowserReplJSONRequestBody{Code: `await new Promise(() => {})`, TimeoutSec: &timeoutSec},
})
done <- err
}()
require.Eventually(t, func() bool { return browserReplBusy(svc) }, time.Second, 10*time.Millisecond)
cancel()
require.ErrorIs(t, <-done, context.Canceled)

start := time.Now()
reset := true
fresh := executeBrowserRepl(t, svc, &oapi.ExecuteBrowserReplJSONRequestBody{Reset: &reset})
assert.Less(t, time.Since(start), 10*time.Second)
assert.NotEqual(t, initial.ReplId, fresh.ReplId)
}
222 changes: 222 additions & 0 deletions server/cmd/api/api/idempotency.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
package api

import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"sync"
"time"
)

const (
maxIdempotencyKeyLength = 255

// idempotencyRetention bounds how long a finished call's result is kept
// for retries. Clients retry within seconds.
idempotencyRetention = 10 * time.Minute

// idempotencyAbandonGrace is how long a call keeps running after its last
// caller disconnects, waiting for a retry to collect it. The SDKs wait at
// most 8 seconds between retries.
idempotencyAbandonGrace = 10 * time.Second
)

var (
errIdempotencyKeyReused = errors.New("Idempotency-Key was already used for a different request")
errIdempotentCallsBusy = errors.New("too many calls with a new Idempotency-Key are waiting or running; retry later")
errIdempotentCallAbandoned = errors.New("no caller is waiting for this call")
)

// idempotentCalls runs each Idempotency-Key at most once. A call with a key
// already seen gets the first call's response instead of running again, so a
// client can retry after losing a response without repeating side effects.
//
// Only responses the caller marks as kept are remembered. Every other outcome
// means the call did not run, so the key is released and a retry runs fresh.
type idempotentCalls[R any] struct {
// maxFinished caps the remembered results, oldest dropped first. Calls
// still running are never dropped.
maxFinished int
// maxPending caps calls waiting or running. They outlive their callers,
// so nothing else bounds them.
maxPending int
retention time.Duration
// abandonGrace is how long a call keeps running with no caller waiting
// for it before it is cancelled.
abandonGrace time.Duration
now func() time.Time

mu sync.Mutex
calls map[string]*idempotentCall[R]
finished []string // keys of kept results, oldest first
pending int
}

type idempotentCall[R any] struct {
fingerprint [sha256.Size]byte
done chan struct{}
finishedAt time.Time
resp R
err error

cancel context.CancelCauseFunc
waiters int // guarded by mu
abandon *time.Timer // guarded by mu; set while no caller waits
}

func newIdempotentCalls[R any](maxFinished, maxPending int) *idempotentCalls[R] {
return &idempotentCalls[R]{
maxFinished: maxFinished,
maxPending: maxPending,
retention: idempotencyRetention,
abandonGrace: idempotencyAbandonGrace,
now: time.Now,
calls: make(map[string]*idempotentCall[R]),
}
}

func validateIdempotencyKey(key string) error {
if key == "" || len(key) > maxIdempotencyKeyLength {
return fmt.Errorf("Idempotency-Key must be 1 to %d characters", maxIdempotencyKeyLength)
}
for i := 0; i < len(key); i++ {
if key[i] < 0x20 || key[i] > 0x7e {
return errors.New("Idempotency-Key must be printable ASCII")
}
}
return nil
}

// idempotencyFingerprint identifies a request's parameters, so a key reused
// for a different request can be refused.
func idempotencyFingerprint(request any) [sha256.Size]byte {
b, err := json.Marshal(request)
if err != nil {
panic(fmt.Sprintf("idempotency fingerprint: %v", err))
}
return sha256.Sum256(b)
}

// Do runs call under key, or returns the response of the call already made
// with it, waiting for that call if it is still running. A caller that
// disconnects does not cancel call: it keeps running while any caller waits
// on the key, and for abandonGrace after the last one leaves, so a retry can
// collect the result. Then its context is cancelled, as a caller's
// disconnect would cancel an unkeyed call. keep reports whether a response
// shows the call ran; any other response releases the key. Do returns
// errIdempotencyKeyReused when key was used with a different fingerprint, and
// errIdempotentCallsBusy without running call when too many are pending.
func (c *idempotentCalls[R]) Do(ctx context.Context, key string, fingerprint [sha256.Size]byte, call func(context.Context) (R, error), keep func(R) bool) (R, error) {
var zero R
c.mu.Lock()
c.expireLocked()
entry, ok := c.calls[key]
switch {
case ok && entry.fingerprint != fingerprint:
c.mu.Unlock()
return zero, errIdempotencyKeyReused
case ok:
c.attachLocked(entry)
case c.pending >= c.maxPending:
c.mu.Unlock()
return zero, errIdempotentCallsBusy
default:
var callCtx context.Context
entry = &idempotentCall[R]{fingerprint: fingerprint, done: make(chan struct{}), waiters: 1}
callCtx, entry.cancel = context.WithCancelCause(context.WithoutCancel(ctx))
c.calls[key] = entry
c.pending++
go c.run(callCtx, key, entry, call, keep)
}
c.mu.Unlock()
defer c.detach(entry)

select {
case <-entry.done:
return entry.resp, entry.err
case <-ctx.Done():
return zero, context.Cause(ctx)
}
}

// attachLocked records a caller waiting on entry. The caller must hold mu.
func (c *idempotentCalls[R]) attachLocked(entry *idempotentCall[R]) {
entry.waiters++
if entry.abandon != nil {
entry.abandon.Stop()
entry.abandon = nil
}
}

// detach records that a caller stopped waiting on entry. When it was the last
// one and the call is still running, the call is cancelled unless another
// caller attaches within abandonGrace.
func (c *idempotentCalls[R]) detach(entry *idempotentCall[R]) {
c.mu.Lock()
defer c.mu.Unlock()
entry.waiters--
select {
case <-entry.done:
return
default:
}
if entry.waiters > 0 {
return
}
var timer *time.Timer
timer = time.AfterFunc(c.abandonGrace, func() {
c.mu.Lock()
defer c.mu.Unlock()
if entry.abandon == timer {
entry.cancel(errIdempotentCallAbandoned)
}
})
entry.abandon = timer
}

// run makes the call for entry, then keeps or releases its result.
func (c *idempotentCalls[R]) run(ctx context.Context, key string, entry *idempotentCall[R], call func(context.Context) (R, error), keep func(R) bool) {
entry.resp, entry.err = runRecovered(ctx, call)
entry.cancel(nil)
c.mu.Lock()
defer c.mu.Unlock()
if entry.abandon != nil {
entry.abandon.Stop()
entry.abandon = nil
}
c.pending--
if entry.err == nil && keep(entry.resp) {
entry.finishedAt = c.now()
c.finished = append(c.finished, key)
for len(c.finished) > c.maxFinished {
delete(c.calls, c.finished[0])
c.finished = c.finished[1:]
}
} else {
delete(c.calls, key)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Abandoned calls consume idempotency keys

Medium Severity

After abandonGrace, cancel turns into a 200 in both handlers: Playwright maps admission or setup cancel to execution failed before any code runs, and REPL maps a mid-read cancel to a terminated 200. keep then stores that abort, so a later retry with the same key receives the cached failure instead of running again.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 00d6058. Configure here.

close(entry.done)
}

// runRecovered turns a panic in call into an error. call runs off the request
// goroutine, outside the router's panic recovery.
func runRecovered[R any](ctx context.Context, call func(context.Context) (R, error)) (resp R, err error) {
defer func() {
if p := recover(); p != nil {
err = fmt.Errorf("panic: %v", p)
}
}()
return call(ctx)
}

// expireLocked drops kept results older than the retention period. The caller
// must hold mu.
func (c *idempotentCalls[R]) expireLocked() {
cutoff := c.now().Add(-c.retention)
for len(c.finished) > 0 && c.calls[c.finished[0]].finishedAt.Before(cutoff) {
delete(c.calls, c.finished[0])
c.finished = c.finished[1:]
}
}
Loading
Loading