Skip to content

Commit c29583c

Browse files
authored
Merge pull request #332 from smocker-dev/fix/session-race
fix(server): create the default session atomically (no duplicate "Session #1")
2 parents 18f7071 + d2c4b9c commit c29583c

2 files changed

Lines changed: 40 additions & 11 deletions

File tree

server/services/mocks.go

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,16 @@ func (s *mocks) GetHistoryByPath(sessionID, filterPath string) (types.History, e
302302
}
303303

304304
func (s *mocks) NewSession(name string) *types.Session {
305+
s.mu.Lock()
306+
defer s.mu.Unlock()
307+
return s.newSessionLocked(name)
308+
}
309+
310+
// newSessionLocked appends a new session and returns it. The caller MUST hold s.mu: the default
311+
// name and the carried-over locked mocks are both derived from s.sessions, so the whole thing has
312+
// to happen in one critical section — otherwise two concurrent creators can read the same length
313+
// and both mint e.g. "Session #1" (the bug behind the flaky rename e2e test).
314+
func (s *mocks) newSessionLocked(name string) *types.Session {
305315
if strings.TrimSpace(name) == "" {
306316
name = fmt.Sprintf("Session #%d", len(s.sessions)+1)
307317
}
@@ -313,21 +323,16 @@ func (s *mocks) NewSession(name string) *types.Session {
313323
history = types.History{}
314324
}
315325

326+
// Carry over the locked mocks from the current last session, reset for the new one.
316327
mocks := types.Mocks{}
317328
if len(s.sessions) > 0 {
318-
session := s.GetLastSession()
319-
s.mu.Lock()
320-
for _, mock := range session.Mocks {
329+
for _, mock := range s.sessions[len(s.sessions)-1].Mocks {
321330
if mock.State.Locked {
322331
mocks = append(mocks, mock.CloneAndReset())
323332
}
324333
}
325-
s.mu.Unlock()
326334
}
327335

328-
s.mu.Lock()
329-
defer s.mu.Unlock()
330-
331336
session := &types.Session{
332337
ID: types.NewID(),
333338
Name: name,
@@ -384,12 +389,10 @@ func (s *mocks) DeleteSession(id string) error {
384389

385390
func (s *mocks) GetLastSession() *types.Session {
386391
s.mu.Lock()
392+
defer s.mu.Unlock()
387393
if len(s.sessions) == 0 {
388-
s.mu.Unlock()
389-
s.NewSession("")
390-
s.mu.Lock()
394+
return s.newSessionLocked("").Clone()
391395
}
392-
defer s.mu.Unlock()
393396
return s.sessions[len(s.sessions)-1].Clone()
394397
}
395398

server/services/mocks_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package services
33
import (
44
"os"
55
"path/filepath"
6+
"sync"
67
"testing"
78

89
"github.com/smocker-dev/smocker/server/types"
@@ -18,6 +19,31 @@ func newTestMocks(t *testing.T) Mocks {
1819
return svc
1920
}
2021

22+
// TestGetLastSessionConcurrent guards the TOCTOU race that let concurrent callers each create a
23+
// default session (two "Session #1") when the list was empty — the source of the flaky "rename a
24+
// session" e2e test. Run under -race.
25+
func TestGetLastSessionConcurrent(t *testing.T) {
26+
svc := newTestMocks(t)
27+
28+
const n = 50
29+
start := make(chan struct{})
30+
var wg sync.WaitGroup
31+
wg.Add(n)
32+
for i := 0; i < n; i++ {
33+
go func() {
34+
defer wg.Done()
35+
<-start
36+
_ = svc.GetLastSession()
37+
}()
38+
}
39+
close(start) // release all goroutines at once to maximize contention
40+
wg.Wait()
41+
42+
if got := len(svc.GetSessions()); got != 1 {
43+
t.Fatalf("concurrent GetLastSession on an empty service created %d sessions, want 1", got)
44+
}
45+
}
46+
2147
func mockFromYAML(t *testing.T, s string) *types.Mock {
2248
t.Helper()
2349
var m types.Mock

0 commit comments

Comments
 (0)