@@ -0,0 +1,953 @@
17+ "github.com/charmbracelet/crush/internal/csync"
18+ "github.com/charmbracelet/crush/internal/proto"
19+ "github.com/google/uuid"
20+ "github.com/stretchr/testify/require"
23+// newTestBackend returns a Backend whose teardown path skips any
24+// real [app.App] shutdown work. Useful for state-machine tests that
25+// install synthetic workspaces directly via insertTestWorkspace.
26+func newTestBackend(t *testing.T) (*Backend, *atomic.Int32) {
28+ var shutdownCount atomic.Int32
30+ workspaces: csync.NewMap[string, *Workspace](),
31+ pathIndex: make(map[string]string),
32+ ctx: context.Background(),
33+ createGrace: 50 * time.Millisecond,
34+ shutdownFn: func() { shutdownCount.Add(1) },
36+ return b, &shutdownCount
39+// insertTestWorkspace installs a synthetic workspace into b at the
40+// given resolved path. Its shutdownFn is recorded in the returned
41+// counter so tests can assert it ran exactly once.
42+func insertTestWorkspace(t *testing.T, b *Backend, key string) (*Workspace, *atomic.Int32) {
44+ var shutdowns atomic.Int32
46+ ID: uuid.New().String(),
49+ clients: make(map[string]*clientState),
50+ shutdownFn: func() { shutdowns.Add(1) },
53+ b.workspaces.Set(ws.ID, ws)
54+ b.pathIndex[key] = ws.ID
56+ return ws, &shutdowns
59+func newClientID(t *testing.T) string {
61+ return uuid.New().String()
64+func TestResolveWorkspaceKey_AbsoluteAndSymlink(t *testing.T) {
68+ real, err := filepath.EvalSymlinks(tmp)
69+ require.NoError(t, err)
71+ got, err := resolveWorkspaceKey(tmp)
72+ require.NoError(t, err)
73+ require.Equal(t, real, got)
76+func TestResolveWorkspaceKey_NonExistentFallback(t *testing.T) {
79+ missing := filepath.Join(t.TempDir(), "does", "not", "exist")
80+ got, err := resolveWorkspaceKey(missing)
81+ require.NoError(t, err)
82+ abs, err := filepath.Abs(missing)
83+ require.NoError(t, err)
84+ require.Equal(t, abs, got)
87+func TestValidateClientID(t *testing.T) {
90+ _, err := validateClientID("")
91+ require.ErrorIs(t, err, ErrInvalidClientID)
92+ _, err = validateClientID("not-a-uuid")
93+ require.ErrorIs(t, err, ErrInvalidClientID)
95+ id := uuid.New().String()
96+ got, err := validateClientID(id)
97+ require.NoError(t, err)
98+ require.Equal(t, id, got)
101+func TestRegisterClient_Idempotent(t *testing.T) {
104+ b, _ := newTestBackend(t)
105+ ws, _ := insertTestWorkspace(t, b, "/tmp/a")
107+ cid := newClientID(t)
108+ b.registerClient(ws, cid)
109+ b.registerClient(ws, cid)
112+ defer ws.clientsMu.Unlock()
113+ require.Len(t, ws.clients, 1)
114+ require.NotNil(t, ws.clients[cid].holdTimer)
115+ require.Equal(t, 0, ws.clients[cid].streams)
118+func TestAttachClient_ConsumesHold(t *testing.T) {
121+ b, _ := newTestBackend(t)
122+ ws, shutdowns := insertTestWorkspace(t, b, "/tmp/a")
124+ cid := newClientID(t)
125+ b.registerClient(ws, cid)
126+ require.NoError(t, b.AttachClient(ws.ID, cid))
129+ require.Len(t, ws.clients, 1)
130+ require.Nil(t, ws.clients[cid].holdTimer, "attach must stop the grace timer")
131+ require.Equal(t, 1, ws.clients[cid].streams)
132+ ws.clientsMu.Unlock()
134+ // Wait past the grace window: a stopped timer must not fire.
135+ time.Sleep(150 * time.Millisecond)
136+ require.Equal(t, int32(0), shutdowns.Load(), "workspace must not be torn down while attached")
139+func TestAttachClient_WithoutPriorCreate(t *testing.T) {
142+ b, _ := newTestBackend(t)
143+ ws, _ := insertTestWorkspace(t, b, "/tmp/a")
145+ cid := newClientID(t)
146+ require.NoError(t, b.AttachClient(ws.ID, cid))
149+ defer ws.clientsMu.Unlock()
150+ require.Len(t, ws.clients, 1)
151+ require.Equal(t, 1, ws.clients[cid].streams)
152+ require.Nil(t, ws.clients[cid].holdTimer)
155+func TestAttachClient_DuplicateStreams(t *testing.T) {
158+ b, _ := newTestBackend(t)
159+ ws, shutdowns := insertTestWorkspace(t, b, "/tmp/a")
161+ cid := newClientID(t)
162+ require.NoError(t, b.AttachClient(ws.ID, cid))
163+ require.NoError(t, b.AttachClient(ws.ID, cid))
166+ require.Equal(t, 2, ws.clients[cid].streams)
167+ ws.clientsMu.Unlock()
169+ b.DetachClient(ws.ID, cid)
171+ require.Equal(t, 1, ws.clients[cid].streams)
172+ ws.clientsMu.Unlock()
173+ require.Equal(t, int32(0), shutdowns.Load())
175+ b.DetachClient(ws.ID, cid)
176+ require.Equal(t, int32(1), shutdowns.Load(), "second detach tears down the workspace")
179+func TestDetachClient_LastStreamTearsDown(t *testing.T) {
182+ b, srvShutdowns := newTestBackend(t)
183+ ws, wsShutdowns := insertTestWorkspace(t, b, "/tmp/a")
185+ cid := newClientID(t)
186+ b.registerClient(ws, cid)
187+ require.NoError(t, b.AttachClient(ws.ID, cid))
188+ b.DetachClient(ws.ID, cid)
190+ require.Equal(t, int32(1), wsShutdowns.Load())
191+ require.Equal(t, int32(1), srvShutdowns.Load(), "last workspace shut down must trigger server shutdown")
192+ _, err := b.GetWorkspace(ws.ID)
193+ require.ErrorIs(t, err, ErrWorkspaceNotFound)
196+func TestHoldExpiry_TearsDown(t *testing.T) {
199+ b, srvShutdowns := newTestBackend(t)
200+ ws, wsShutdowns := insertTestWorkspace(t, b, "/tmp/a")
202+ cid := newClientID(t)
203+ b.registerClient(ws, cid)
205+ require.Eventually(t, func() bool {
206+ return wsShutdowns.Load() == 1 && srvShutdowns.Load() == 1
207+ }, 1*time.Second, 5*time.Millisecond)
210+func TestReleaseHold_NoStreams(t *testing.T) {
213+ b, _ := newTestBackend(t)
214+ ws, shutdowns := insertTestWorkspace(t, b, "/tmp/a")
216+ cid := newClientID(t)
217+ b.registerClient(ws, cid)
218+ require.NoError(t, b.releaseHold(ws.ID, cid))
220+ require.Equal(t, int32(1), shutdowns.Load())
222+ require.NoError(t, b.releaseHold(ws.ID, cid))
223+ require.Equal(t, int32(1), shutdowns.Load())
226+func TestReleaseHold_WithActiveStream(t *testing.T) {
229+ b, _ := newTestBackend(t)
230+ ws, shutdowns := insertTestWorkspace(t, b, "/tmp/a")
232+ cid := newClientID(t)
233+ b.registerClient(ws, cid)
234+ require.NoError(t, b.AttachClient(ws.ID, cid))
235+ require.NoError(t, b.releaseHold(ws.ID, cid))
238+ require.Equal(t, 1, ws.clients[cid].streams)
239+ require.Nil(t, ws.clients[cid].holdTimer)
240+ ws.clientsMu.Unlock()
241+ require.Equal(t, int32(0), shutdowns.Load())
243+ b.DetachClient(ws.ID, cid)
244+ require.Equal(t, int32(1), shutdowns.Load())
247+func TestReleaseHoldThenAttach(t *testing.T) {
250+ b, _ := newTestBackend(t)
251+ ws, shutdowns := insertTestWorkspace(t, b, "/tmp/a")
253+ cid := newClientID(t)
254+ require.NoError(t, b.releaseHold(ws.ID, cid)) // no entry yet — no-op.
255+ require.NoError(t, b.AttachClient(ws.ID, cid))
257+ require.Equal(t, 1, ws.clients[cid].streams)
258+ ws.clientsMu.Unlock()
259+ require.NoError(t, b.releaseHold(ws.ID, cid)) // hold-only no-op (no hold timer).
260+ require.Equal(t, int32(0), shutdowns.Load())
261+ b.DetachClient(ws.ID, cid)
262+ require.Equal(t, int32(1), shutdowns.Load())
265+func TestRefcountWithSecondClient(t *testing.T) {
268+ b, _ := newTestBackend(t)
269+ ws, shutdowns := insertTestWorkspace(t, b, "/tmp/a")
271+ cidA := newClientID(t)
272+ cidB := newClientID(t)
273+ b.registerClient(ws, cidA)
274+ require.NoError(t, b.AttachClient(ws.ID, cidA))
275+ b.registerClient(ws, cidB)
276+ require.NoError(t, b.AttachClient(ws.ID, cidB))
278+ b.DetachClient(ws.ID, cidA)
280+ require.Contains(t, ws.clients, cidB)
281+ require.NotContains(t, ws.clients, cidA)
282+ ws.clientsMu.Unlock()
283+ require.Equal(t, int32(0), shutdowns.Load(), "workspace survives while second client attached")
285+ b.DetachClient(ws.ID, cidB)
286+ require.Equal(t, int32(1), shutdowns.Load())
289+func TestAttachClient_InvalidID(t *testing.T) {
292+ b, _ := newTestBackend(t)
293+ ws, _ := insertTestWorkspace(t, b, "/tmp/a")
295+ require.ErrorIs(t, b.AttachClient(ws.ID, ""), ErrInvalidClientID)
296+ require.ErrorIs(t, b.AttachClient(ws.ID, "not-a-uuid"), ErrInvalidClientID)
299+func TestDeleteWorkspace_RejectsBadClientID(t *testing.T) {
302+ b, _ := newTestBackend(t)
303+ ws, _ := insertTestWorkspace(t, b, "/tmp/a")
305+ require.ErrorIs(t, b.DeleteWorkspace(ws.ID, ""), ErrInvalidClientID)
306+ require.ErrorIs(t, b.DeleteWorkspace(ws.ID, "not-a-uuid"), ErrInvalidClientID)
309+// TestHoldExpiry_RaceWithAttach checks that, when the grace timer fires
310+// while a concurrent AttachClient call is in flight, the workspace ends
311+// up either fully attached or fully torn down — never in a half-state.
312+func TestHoldExpiry_RaceWithAttach(t *testing.T) {
316+ b, _ := newTestBackend(t)
317+ // Tighten the grace window further to force the race.
318+ b.createGrace = 1 * time.Millisecond
319+ ws, shutdowns := insertTestWorkspace(t, b, "/tmp/race")
321+ cid := newClientID(t)
322+ b.registerClient(ws, cid)
323+ // Attach concurrently with the very short grace timer.
324+ errCh := make(chan error, 1)
325+ go func() { errCh <- b.AttachClient(ws.ID, cid) }()
328+ // Wait for any pending timer to settle.
329+ time.Sleep(10 * time.Millisecond)
332+ gotShutdown := shutdowns.Load() == 1
333+ cs, present := ws.clients[cid]
336+ gotHoldTimer *time.Timer
339+ gotStreams = cs.streams
340+ gotHoldTimer = cs.holdTimer
342+ ws.clientsMu.Unlock()
343+ // Either the workspace was torn down OR the client is
344+ // attached with streams==1 and the hold timer cleared.
345+ // The state must be consistent: if shutdown, client is
346+ // gone; if attached, no teardown and streams==1.
348+ require.False(t, present, "iter %d: shutdown but client still present", i)
350+ require.True(t, present, "iter %d: not shutdown but client missing", i)
351+ require.Equal(t, 1, gotStreams, "iter %d: attach winner must leave streams=1", i)
352+ require.Nil(t, gotHoldTimer, "iter %d: attach winner must clear holdTimer", i)
357+// TestConcurrentAttachDetach exercises the state machine under
358+// parallel attach/detach pressure with the race detector.
359+func TestConcurrentAttachDetach(t *testing.T) {
362+ b, _ := newTestBackend(t)
363+ ws, _ := insertTestWorkspace(t, b, "/tmp/a")
365+ cid := newClientID(t)
366+ b.registerClient(ws, cid)
367+ require.NoError(t, b.AttachClient(ws.ID, cid)) // ensure refcount stays > 0.
370+ var wg sync.WaitGroup
375+ cid2 := newClientID(t)
376+ _ = b.AttachClient(ws.ID, cid2)
377+ b.DetachClient(ws.ID, cid2)
383+ defer ws.clientsMu.Unlock()
384+ require.Len(t, ws.clients, 1)
385+ require.Contains(t, ws.clients, cid)
388+// TestPathDedupe_FullCreate exercises CreateWorkspace end-to-end
389+// (config init, real app.App). Two CreateWorkspace calls at the same
390+// path return the same workspace ID and share the clients map.
391+func TestPathDedupe_FullCreate(t *testing.T) {
392+ t.Setenv("HOME", t.TempDir())
393+ t.Setenv("XDG_CACHE_HOME", t.TempDir())
394+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
395+ t.Setenv("XDG_DATA_HOME", t.TempDir())
398+ dataDir := t.TempDir()
400+ b := New(context.Background(), nil, func() {})
401+ b.SetCreateGrace(2 * time.Second)
402+ t.Cleanup(func() { drainBackend(t, b) })
404+ cidA := uuid.New().String()
405+ cidB := uuid.New().String()
407+ wsA, protoA, err := b.CreateWorkspace(protoWS(cwd, dataDir, cidA))
408+ require.NoError(t, err)
409+ require.NotEmpty(t, protoA.ID)
410+ require.Equal(t, protoA.DataDir, wsA.Cfg.Config().Options.DataDirectory)
412+ wsB, protoB, err := b.CreateWorkspace(protoWS(cwd, dataDir, cidB))
413+ require.NoError(t, err)
414+ require.Equal(t, wsA.ID, wsB.ID, "second create at same path must return existing workspace")
415+ require.Equal(t, protoA.ID, protoB.ID)
417+ wsA.clientsMu.Lock()
418+ require.Contains(t, wsA.clients, cidA)
419+ require.Contains(t, wsA.clients, cidB)
420+ wsA.clientsMu.Unlock()
423+// TestPathDedupe_DifferentPaths_DifferentWorkspaces confirms that two
424+// CreateWorkspace calls at distinct paths produce distinct workspaces.
425+func TestPathDedupe_DifferentPaths_DifferentWorkspaces(t *testing.T) {
426+ t.Setenv("HOME", t.TempDir())
427+ t.Setenv("XDG_CACHE_HOME", t.TempDir())
428+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
429+ t.Setenv("XDG_DATA_HOME", t.TempDir())
433+ dataA := t.TempDir()
434+ dataB := t.TempDir()
436+ b := New(context.Background(), nil, func() {})
437+ b.SetCreateGrace(2 * time.Second)
438+ t.Cleanup(func() { drainBackend(t, b) })
440+ wsA, _, err := b.CreateWorkspace(protoWS(cwdA, dataA, uuid.New().String()))
441+ require.NoError(t, err)
442+ wsB, _, err := b.CreateWorkspace(protoWS(cwdB, dataB, uuid.New().String()))
443+ require.NoError(t, err)
444+ require.NotEqual(t, wsA.ID, wsB.ID)
447+// TestPathDedupe_FirstWinsKeepsOriginalEnv verifies that the second
448+// create at the same path returns the *originating* client's Env in
449+// its proto and does not mutate the existing workspace's YOLO/Debug
451+func TestPathDedupe_FirstWinsKeepsOriginalEnv(t *testing.T) {
452+ t.Setenv("HOME", t.TempDir())
453+ t.Setenv("XDG_CACHE_HOME", t.TempDir())
454+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
455+ t.Setenv("XDG_DATA_HOME", t.TempDir())
458+ dataDir := t.TempDir()
460+ b := New(context.Background(), nil, func() {})
461+ b.SetCreateGrace(2 * time.Second)
462+ t.Cleanup(func() { drainBackend(t, b) })
464+ originalEnv := []string{"FOO=bar"}
465+ argsA := protoWS(cwd, dataDir, uuid.New().String())
467+ argsA.Env = originalEnv
468+ wsA, protoA, err := b.CreateWorkspace(argsA)
469+ require.NoError(t, err)
470+ require.True(t, protoA.YOLO)
471+ require.Equal(t, originalEnv, protoA.Env)
473+ argsB := protoWS(cwd, dataDir, uuid.New().String())
476+ argsB.Env = []string{"BAZ=qux"}
477+ _, protoB, err := b.CreateWorkspace(argsB)
478+ require.NoError(t, err)
479+ require.Equal(t, protoA.ID, protoB.ID)
480+ require.True(t, protoB.YOLO, "first wins: YOLO must remain true")
481+ require.Equal(t, originalEnv, protoB.Env, "proto must carry the originating client's Env")
482+ require.Equal(t, wsA.Cfg.Overrides().SkipPermissionRequests, true)
485+// TestPathDedupe_Symlink confirms two paths that resolve to the same
486+// target share a workspace.
487+func TestPathDedupe_Symlink(t *testing.T) {
488+ if runtime.GOOS == "windows" {
489+ t.Skip("symlink semantics differ on windows")
491+ t.Setenv("HOME", t.TempDir())
492+ t.Setenv("XDG_CACHE_HOME", t.TempDir())
493+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
494+ t.Setenv("XDG_DATA_HOME", t.TempDir())
497+ link := filepath.Join(t.TempDir(), "link")
498+ require.NoError(t, os.Symlink(real, link))
499+ dataDir := t.TempDir()
501+ b := New(context.Background(), nil, func() {})
502+ b.SetCreateGrace(2 * time.Second)
503+ t.Cleanup(func() { drainBackend(t, b) })
505+ wsA, _, err := b.CreateWorkspace(protoWS(real, dataDir, uuid.New().String()))
506+ require.NoError(t, err)
507+ wsB, _, err := b.CreateWorkspace(protoWS(link, dataDir, uuid.New().String()))
508+ require.NoError(t, err)
509+ require.Equal(t, wsA.ID, wsB.ID)
512+// TestPathDedupe_NonExistentPath ensures CreateWorkspace tolerates a
513+// path that does not yet exist (EvalSymlinks falls back to Abs).
514+func TestPathDedupe_NonExistentPath(t *testing.T) {
515+ t.Setenv("HOME", t.TempDir())
516+ t.Setenv("XDG_CACHE_HOME", t.TempDir())
517+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
518+ t.Setenv("XDG_DATA_HOME", t.TempDir())
520+ parent := t.TempDir()
521+ missing := filepath.Join(parent, "does-not-exist")
522+ dataDir := t.TempDir()
524+ b := New(context.Background(), nil, func() {})
525+ b.SetCreateGrace(2 * time.Second)
526+ t.Cleanup(func() { drainBackend(t, b) })
528+ _, p, err := b.CreateWorkspace(protoWS(missing, dataDir, uuid.New().String()))
529+ require.NoError(t, err)
530+ require.NotEmpty(t, p.ID)
533+// TestCreateWorkspace_IdempotentSameClient checks that a duplicate
534+// create from the same client at the same path does not produce a
536+func TestCreateWorkspace_IdempotentSameClient(t *testing.T) {
537+ t.Setenv("HOME", t.TempDir())
538+ t.Setenv("XDG_CACHE_HOME", t.TempDir())
539+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
540+ t.Setenv("XDG_DATA_HOME", t.TempDir())
543+ dataDir := t.TempDir()
544+ b := New(context.Background(), nil, func() {})
545+ b.SetCreateGrace(2 * time.Second)
546+ t.Cleanup(func() { drainBackend(t, b) })
548+ cid := uuid.New().String()
549+ ws1, _, err := b.CreateWorkspace(protoWS(cwd, dataDir, cid))
550+ require.NoError(t, err)
551+ ws2, _, err := b.CreateWorkspace(protoWS(cwd, dataDir, cid))
552+ require.NoError(t, err)
553+ require.Equal(t, ws1.ID, ws2.ID)
555+ ws1.clientsMu.Lock()
556+ require.Len(t, ws1.clients, 1, "duplicate create from same client must not double the claim")
557+ ws1.clientsMu.Unlock()
560+// TestPathDedupe_ParallelCreates ensures two simultaneous CreateWorkspace
561+// calls at the same path produce the same workspace and the clients map
562+// contains both client IDs.
563+func TestPathDedupe_ParallelCreates(t *testing.T) {
564+ t.Setenv("HOME", t.TempDir())
565+ t.Setenv("XDG_CACHE_HOME", t.TempDir())
566+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
567+ t.Setenv("XDG_DATA_HOME", t.TempDir())
570+ dataDir := t.TempDir()
572+ b := New(context.Background(), nil, func() {})
573+ b.SetCreateGrace(2 * time.Second)
574+ t.Cleanup(func() { drainBackend(t, b) })
576+ cidA := uuid.New().String()
577+ cidB := uuid.New().String()
579+ type result struct {
581+ proto proto.Workspace
584+ ch := make(chan result, 2)
585+ start := make(chan struct{})
588+ ws, p, err := b.CreateWorkspace(protoWS(cwd, dataDir, cidA))
589+ ch <- result{ws, p, err}
593+ ws, p, err := b.CreateWorkspace(protoWS(cwd, dataDir, cidB))
594+ ch <- result{ws, p, err}
599+ require.NoError(t, r1.err)
600+ require.NoError(t, r2.err)
601+ require.Equal(t, r1.ws.ID, r2.ws.ID, "both creates must converge on one workspace ID")
605+ defer ws.clientsMu.Unlock()
606+ require.Contains(t, ws.clients, cidA)
607+ require.Contains(t, ws.clients, cidB)
610+// TestCreateWorkspace_RejectsBadClientID covers the 400 path from the
612+func TestCreateWorkspace_RejectsBadClientID(t *testing.T) {
615+ b := New(context.Background(), nil, func() {})
617+ _, _, err := b.CreateWorkspace(protoWS("/tmp/x", t.TempDir(), ""))
618+ require.ErrorIs(t, err, ErrInvalidClientID)
619+ _, _, err = b.CreateWorkspace(protoWS("/tmp/x", t.TempDir(), "not-a-uuid"))
620+ require.ErrorIs(t, err, ErrInvalidClientID)
623+// drainBackend tears the backend down at the end of a test by deleting
624+// every remaining workspace. Necessary so the test process doesn't
625+// leak goroutines or DB handles from the embedded [app.App] instances.
626+func drainBackend(t *testing.T, b *Backend) {
628+ for _, ws := range b.workspaces.Seq2() {
630+ ids := make([]string, 0, len(ws.clients))
631+ for id := range ws.clients {
632+ ids = append(ids, id)
634+ ws.clientsMu.Unlock()
635+ for _, cid := range ids {
636+ _ = b.releaseHold(ws.ID, cid)
641+func protoWS(path, dataDir, clientID string) proto.Workspace {
642+ return proto.Workspace{Path: path, DataDir: dataDir, ClientID: clientID}
645+// captureDebugLogs installs a buffer-backed slog handler at Debug
646+// level for the duration of the test, returning the buffer. The
647+// previous default handler is restored via t.Cleanup.
648+func captureDebugLogs(t *testing.T) *bytes.Buffer {
650+ var buf bytes.Buffer
651+ prev := slog.Default()
652+ handler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})
653+ slog.SetDefault(slog.New(handler))
654+ t.Cleanup(func() { slog.SetDefault(prev) })
658+// xdgIsolated points HOME and XDG_* variables at fresh tempdirs so
659+// CreateWorkspace's config loading does not interfere with the host
660+// machine's real config.
661+func xdgIsolated(t *testing.T) {
663+ t.Setenv("HOME", t.TempDir())
664+ t.Setenv("XDG_CACHE_HOME", t.TempDir())
665+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
666+ t.Setenv("XDG_DATA_HOME", t.TempDir())
669+// TestFirstWinsMismatch_LogsOnFlagDifferences verifies that the
670+// debug mismatch line is emitted when any of YOLO, Debug, DataDir,
671+// or Env differs between the first and second CreateWorkspace at
672+// the same path, and that the existing workspace's Debug flag is
674+func TestFirstWinsMismatch_LogsOnFlagDifferences(t *testing.T) {
677+ mutate func(*proto.Workspace)
681+ mutate: func(p *proto.Workspace) { p.YOLO = true },
685+ mutate: func(p *proto.Workspace) { p.Debug = true },
689+ mutate: func(p *proto.Workspace) { p.DataDir = "" },
693+ mutate: func(p *proto.Workspace) { p.Env = []string{"NEW=val"} },
697+ for _, tc := range tests {
698+ t.Run(tc.name, func(t *testing.T) {
701+ dataDir := t.TempDir()
703+ buf := captureDebugLogs(t)
704+ b := New(context.Background(), nil, func() {})
705+ b.SetCreateGrace(2 * time.Second)
706+ t.Cleanup(func() { drainBackend(t, b) })
708+ argsA := protoWS(cwd, dataDir, uuid.New().String())
709+ argsA.Env = []string{"FOO=bar"}
710+ wsA, _, err := b.CreateWorkspace(argsA)
711+ require.NoError(t, err)
712+ originalDebug := wsA.Cfg.Config().Options.Debug
713+ originalYOLO := wsA.Cfg.Overrides().SkipPermissionRequests
715+ argsB := protoWS(cwd, dataDir, uuid.New().String())
716+ argsB.Env = []string{"FOO=bar"} // identical by default
718+ _, _, err = b.CreateWorkspace(argsB)
719+ require.NoError(t, err)
723+ "Workspace flag mismatch on duplicate create",
724+ "expected debug log for mismatching %s", tc.name,
726+ // Existing workspace's YOLO and Debug must not change.
727+ require.Equal(t, originalYOLO, wsA.Cfg.Overrides().SkipPermissionRequests, "YOLO must be immutable on first-wins")
728+ require.Equal(t, originalDebug, wsA.Cfg.Config().Options.Debug, "Debug must be immutable on first-wins")
733+// TestFirstWinsMismatch_NoLogWhenIdentical confirms identical args
734+// do not emit the mismatch log line.
735+func TestFirstWinsMismatch_NoLogWhenIdentical(t *testing.T) {
738+ dataDir := t.TempDir()
740+ buf := captureDebugLogs(t)
741+ b := New(context.Background(), nil, func() {})
742+ b.SetCreateGrace(2 * time.Second)
743+ t.Cleanup(func() { drainBackend(t, b) })
745+ argsA := protoWS(cwd, dataDir, uuid.New().String())
746+ argsA.Env = []string{"FOO=bar"}
747+ _, _, err := b.CreateWorkspace(argsA)
748+ require.NoError(t, err)
750+ argsB := protoWS(cwd, dataDir, uuid.New().String())
751+ argsB.Env = []string{"FOO=bar"}
752+ _, _, err = b.CreateWorkspace(argsB)
753+ require.NoError(t, err)
756+ strings.Contains(buf.String(), "Workspace flag mismatch on duplicate create"),
757+ "identical args must not log a mismatch: %s", buf.String())
760+// TestRaceTwoClientsAttachOneDetaches exercises the PLAN-required
761+// race scenario: two clients attach concurrently, then one detaches.
762+// The workspace must remain alive with refcount==1 and the clients
763+// map must reflect the remaining client only.
764+func TestRaceTwoClientsAttachOneDetaches(t *testing.T) {
767+ b, _ := newTestBackend(t)
768+ ws, shutdowns := insertTestWorkspace(t, b, "/tmp/race-two")
770+ cidA := newClientID(t)
771+ cidB := newClientID(t)
773+ var wg sync.WaitGroup
777+ require.NoError(t, b.AttachClient(ws.ID, cidA))
781+ require.NoError(t, b.AttachClient(ws.ID, cidB))
786+ require.Len(t, ws.clients, 2, "both clients must be attached")
787+ ws.clientsMu.Unlock()
789+ b.DetachClient(ws.ID, cidA)
792+ require.Len(t, ws.clients, 1, "refcount must be 1 after one detach")
793+ require.Contains(t, ws.clients, cidB, "remaining client must be cidB")
794+ require.NotContains(t, ws.clients, cidA, "detached client must be removed")
795+ ws.clientsMu.Unlock()
796+ require.Equal(t, int32(0), shutdowns.Load(), "workspace must remain alive")
799+ b.DetachClient(ws.ID, cidB)
800+ require.Equal(t, int32(1), shutdowns.Load())
803+// TestExplicitDeleteThenAttach reproduces the PLAN scenario: start
804+// with a real hold, releaseHold consumes it, AttachClient from the
805+// same clientID creates a fresh entry with streams==1, and calling
806+// releaseHold again is a no-op. A second client keeps the workspace
807+// alive so AttachClient can still resolve the workspace ID after the
808+// first client's hold is released.
809+func TestExplicitDeleteThenAttach(t *testing.T) {
812+ // Large grace window so timers cannot fire during the test
813+ // — we want to exercise the explicit releaseHold path.
814+ b, _ := newTestBackend(t)
815+ b.createGrace = time.Hour
816+ ws, shutdowns := insertTestWorkspace(t, b, "/tmp/delete-then-attach")
818+ // Anchor client keeps the workspace registered in
819+ // b.workspaces across the cid's releaseHold below.
820+ anchor := newClientID(t)
821+ require.NoError(t, b.AttachClient(ws.ID, anchor))
823+ cid := newClientID(t)
824+ // Real hold via registerClient (mirrors CreateWorkspace).
825+ b.registerClient(ws, cid)
827+ require.Contains(t, ws.clients, cid)
828+ require.NotNil(t, ws.clients[cid].holdTimer, "hold must be live")
829+ require.Equal(t, 0, ws.clients[cid].streams)
830+ ws.clientsMu.Unlock()
832+ // releaseHold: consumes the hold and removes the entry
833+ // (streams == 0). The anchor client keeps the workspace
835+ require.NoError(t, b.releaseHold(ws.ID, cid))
836+ require.Equal(t, int32(0), shutdowns.Load(), "anchor must keep workspace alive")
838+ require.NotContains(t, ws.clients, cid, "entry must be removed by releaseHold")
839+ ws.clientsMu.Unlock()
841+ // AttachClient creates a fresh entry with streams==1 and no
843+ require.NoError(t, b.AttachClient(ws.ID, cid))
845+ require.Contains(t, ws.clients, cid, "fresh entry must be created")
846+ require.Equal(t, 1, ws.clients[cid].streams, "fresh attach must start at streams=1")
847+ require.Nil(t, ws.clients[cid].holdTimer, "fresh attach must have no hold timer")
848+ ws.clientsMu.Unlock()
850+ // Calling releaseHold again is a no-op (no hold timer to
851+ // stop, streams > 0 so the entry stays).
852+ require.NoError(t, b.releaseHold(ws.ID, cid))
854+ require.Contains(t, ws.clients, cid, "releaseHold must not touch a stream-only entry")
855+ require.Equal(t, 1, ws.clients[cid].streams)
856+ require.Nil(t, ws.clients[cid].holdTimer)
857+ ws.clientsMu.Unlock()
860+ b.DetachClient(ws.ID, cid)
861+ b.DetachClient(ws.ID, anchor)
862+ require.Equal(t, int32(1), shutdowns.Load())
865+// TestAttachClient_RacesWithTeardown forces AttachClient to compete
866+// with the teardown path triggered by DetachClient. Before the fix,
867+// AttachClient could observe a workspace after teardown had already
868+// decided to remove it (because AttachClient did not synchronize with
869+// Backend.mu), leaving a live stream claim attached to a workspace
870+// that was then removed and shut down. With the fix, the outcome must
871+// be deterministic: either AttachClient won and the workspace is
872+// alive with the client registered, or teardown won and AttachClient
873+// returns ErrWorkspaceNotFound — never a half-state where the
874+// workspace is gone but ws.clients still contains the new client.
875+func TestAttachClient_RacesWithTeardown(t *testing.T) {
878+ for i := range 200 {
879+ b, _ := newTestBackend(t)
880+ // Keep the grace window long so it can't fire during the
881+ // test and confuse the bookkeeping.
882+ b.createGrace = time.Hour
883+ ws, shutdowns := insertTestWorkspace(t, b, "/tmp/race-teardown")
885+ // Seed: cidA holds the workspace open via a stream. The
886+ // imminent DetachClient(cidA) will be the *only* claim
887+ // drop, so teardown will run.
888+ cidA := newClientID(t)
889+ require.NoError(t, b.AttachClient(ws.ID, cidA))
891+ // cidB attempts to attach concurrently with the detach
892+ // that will tear the workspace down.
893+ cidB := newClientID(t)
894+ start := make(chan struct{})
895+ errCh := make(chan error, 1)
896+ detachDone := make(chan struct{})
899+ errCh <- b.AttachClient(ws.ID, cidB)
903+ b.DetachClient(ws.ID, cidA)
908+ // Wait for both goroutines so teardown (including
909+ // shutdownFn) has fully run before we read state.
910+ attachErr := <-errCh
913+ _, wsStillRegistered := b.workspaces.Get(ws.ID)
915+ _, hasA := ws.clients[cidA]
916+ _, hasB := ws.clients[cidB]
917+ clientCount := len(ws.clients)
918+ ws.clientsMu.Unlock()
919+ shutdownCount := shutdowns.Load()
922+ case attachErr == nil:
923+ // AttachClient won. The workspace must be alive
924+ // (registered) with cidB in its clients map. cidA
925+ // may or may not still be there depending on who
926+ // took clientsMu first, but the workspace must
927+ // not have been torn down.
928+ require.True(t, wsStillRegistered,
929+ "iter %d: attach succeeded but workspace was removed", i)
930+ require.True(t, hasB,
931+ "iter %d: attach succeeded but cidB missing from clients", i)
932+ require.Equal(t, int32(0), shutdownCount,
933+ "iter %d: attach succeeded but workspace was shut down", i)
934+ case errors.Is(attachErr, ErrWorkspaceNotFound):
935+ // Teardown won. The workspace must be removed,
936+ // shut down exactly once, and ws.clients must be
937+ // empty (no half-state with cidB inserted into a
938+ // dead workspace's clients map).
939+ require.False(t, wsStillRegistered,
940+ "iter %d: ErrWorkspaceNotFound but workspace still registered", i)
941+ require.Equal(t, int32(1), shutdownCount,
942+ "iter %d: ErrWorkspaceNotFound but shutdown count = %d", i, shutdownCount)
943+ require.False(t, hasA,
944+ "iter %d: teardown won but cidA still in clients", i)
945+ require.False(t, hasB,
946+ "iter %d: teardown won but cidB still in clients (would be the leaked attach)", i)
947+ require.Zero(t, clientCount,
948+ "iter %d: teardown won but clients map is non-empty", i)
950+ t.Fatalf("iter %d: unexpected AttachClient error: %v", i, attachErr)