backend_test.go

  1package backend
  2
  3import (
  4	"bytes"
  5	"context"
  6	"errors"
  7	"log/slog"
  8	"os"
  9	"path/filepath"
 10	"runtime"
 11	"strings"
 12	"sync"
 13	"sync/atomic"
 14	"testing"
 15	"time"
 16
 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"
 21)
 22
 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.
 26func newTestBackend(t *testing.T) (*Backend, *atomic.Int32) {
 27	t.Helper()
 28	var shutdownCount atomic.Int32
 29	b := &Backend{
 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) },
 35	}
 36	return b, &shutdownCount
 37}
 38
 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.
 42func insertTestWorkspace(t *testing.T, b *Backend, key string) (*Workspace, *atomic.Int32) {
 43	t.Helper()
 44	var shutdowns atomic.Int32
 45	ws := &Workspace{
 46		ID:           uuid.New().String(),
 47		Path:         key,
 48		resolvedPath: key,
 49		clients:      make(map[string]*clientState),
 50		shutdownFn:   func() { shutdowns.Add(1) },
 51	}
 52	b.mu.Lock()
 53	b.workspaces.Set(ws.ID, ws)
 54	b.pathIndex[key] = ws.ID
 55	b.mu.Unlock()
 56	return ws, &shutdowns
 57}
 58
 59func newClientID(t *testing.T) string {
 60	t.Helper()
 61	return uuid.New().String()
 62}
 63
 64func TestResolveWorkspaceKey_AbsoluteAndSymlink(t *testing.T) {
 65	t.Parallel()
 66
 67	tmp := t.TempDir()
 68	real, err := filepath.EvalSymlinks(tmp)
 69	require.NoError(t, err)
 70
 71	got, err := resolveWorkspaceKey(tmp)
 72	require.NoError(t, err)
 73	require.Equal(t, real, got)
 74}
 75
 76func TestResolveWorkspaceKey_NonExistentFallback(t *testing.T) {
 77	t.Parallel()
 78
 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)
 85}
 86
 87func TestValidateClientID(t *testing.T) {
 88	t.Parallel()
 89
 90	_, err := validateClientID("")
 91	require.ErrorIs(t, err, ErrInvalidClientID)
 92	_, err = validateClientID("not-a-uuid")
 93	require.ErrorIs(t, err, ErrInvalidClientID)
 94
 95	id := uuid.New().String()
 96	got, err := validateClientID(id)
 97	require.NoError(t, err)
 98	require.Equal(t, id, got)
 99}
100
101func TestRegisterClient_Idempotent(t *testing.T) {
102	t.Parallel()
103
104	b, _ := newTestBackend(t)
105	ws, _ := insertTestWorkspace(t, b, "/tmp/a")
106
107	cid := newClientID(t)
108	b.registerClient(ws, cid)
109	b.registerClient(ws, cid)
110
111	ws.clientsMu.Lock()
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)
116}
117
118func TestAttachClient_ConsumesHold(t *testing.T) {
119	t.Parallel()
120
121	b, _ := newTestBackend(t)
122	ws, shutdowns := insertTestWorkspace(t, b, "/tmp/a")
123
124	cid := newClientID(t)
125	b.registerClient(ws, cid)
126	require.NoError(t, b.AttachClient(ws.ID, cid))
127
128	ws.clientsMu.Lock()
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()
133
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")
137}
138
139func TestAttachClient_WithoutPriorCreate(t *testing.T) {
140	t.Parallel()
141
142	b, _ := newTestBackend(t)
143	ws, _ := insertTestWorkspace(t, b, "/tmp/a")
144
145	cid := newClientID(t)
146	require.NoError(t, b.AttachClient(ws.ID, cid))
147
148	ws.clientsMu.Lock()
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)
153}
154
155func TestAttachClient_DuplicateStreams(t *testing.T) {
156	t.Parallel()
157
158	b, _ := newTestBackend(t)
159	ws, shutdowns := insertTestWorkspace(t, b, "/tmp/a")
160
161	cid := newClientID(t)
162	require.NoError(t, b.AttachClient(ws.ID, cid))
163	require.NoError(t, b.AttachClient(ws.ID, cid))
164
165	ws.clientsMu.Lock()
166	require.Equal(t, 2, ws.clients[cid].streams)
167	ws.clientsMu.Unlock()
168
169	b.DetachClient(ws.ID, cid)
170	ws.clientsMu.Lock()
171	require.Equal(t, 1, ws.clients[cid].streams)
172	ws.clientsMu.Unlock()
173	require.Equal(t, int32(0), shutdowns.Load())
174
175	b.DetachClient(ws.ID, cid)
176	require.Equal(t, int32(1), shutdowns.Load(), "second detach tears down the workspace")
177}
178
179func TestDetachClient_LastStreamTearsDown(t *testing.T) {
180	t.Parallel()
181
182	b, srvShutdowns := newTestBackend(t)
183	ws, wsShutdowns := insertTestWorkspace(t, b, "/tmp/a")
184
185	cid := newClientID(t)
186	b.registerClient(ws, cid)
187	require.NoError(t, b.AttachClient(ws.ID, cid))
188	b.DetachClient(ws.ID, cid)
189
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)
194}
195
196func TestHoldExpiry_TearsDown(t *testing.T) {
197	t.Parallel()
198
199	b, srvShutdowns := newTestBackend(t)
200	ws, wsShutdowns := insertTestWorkspace(t, b, "/tmp/a")
201
202	cid := newClientID(t)
203	b.registerClient(ws, cid)
204
205	require.Eventually(t, func() bool {
206		return wsShutdowns.Load() == 1 && srvShutdowns.Load() == 1
207	}, 1*time.Second, 5*time.Millisecond)
208}
209
210func TestReleaseHold_NoStreams(t *testing.T) {
211	t.Parallel()
212
213	b, _ := newTestBackend(t)
214	ws, shutdowns := insertTestWorkspace(t, b, "/tmp/a")
215
216	cid := newClientID(t)
217	b.registerClient(ws, cid)
218	require.NoError(t, b.releaseHold(ws.ID, cid))
219
220	require.Equal(t, int32(1), shutdowns.Load())
221	// Idempotent.
222	require.NoError(t, b.releaseHold(ws.ID, cid))
223	require.Equal(t, int32(1), shutdowns.Load())
224}
225
226func TestReleaseHold_WithActiveStream(t *testing.T) {
227	t.Parallel()
228
229	b, _ := newTestBackend(t)
230	ws, shutdowns := insertTestWorkspace(t, b, "/tmp/a")
231
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))
236
237	ws.clientsMu.Lock()
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())
242
243	b.DetachClient(ws.ID, cid)
244	require.Equal(t, int32(1), shutdowns.Load())
245}
246
247func TestReleaseHoldThenAttach(t *testing.T) {
248	t.Parallel()
249
250	b, _ := newTestBackend(t)
251	ws, shutdowns := insertTestWorkspace(t, b, "/tmp/a")
252
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))
256	ws.clientsMu.Lock()
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())
263}
264
265func TestRefcountWithSecondClient(t *testing.T) {
266	t.Parallel()
267
268	b, _ := newTestBackend(t)
269	ws, shutdowns := insertTestWorkspace(t, b, "/tmp/a")
270
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))
277
278	b.DetachClient(ws.ID, cidA)
279	ws.clientsMu.Lock()
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")
284
285	b.DetachClient(ws.ID, cidB)
286	require.Equal(t, int32(1), shutdowns.Load())
287}
288
289func TestAttachClient_InvalidID(t *testing.T) {
290	t.Parallel()
291
292	b, _ := newTestBackend(t)
293	ws, _ := insertTestWorkspace(t, b, "/tmp/a")
294
295	require.ErrorIs(t, b.AttachClient(ws.ID, ""), ErrInvalidClientID)
296	require.ErrorIs(t, b.AttachClient(ws.ID, "not-a-uuid"), ErrInvalidClientID)
297}
298
299func TestDeleteWorkspace_RejectsBadClientID(t *testing.T) {
300	t.Parallel()
301
302	b, _ := newTestBackend(t)
303	ws, _ := insertTestWorkspace(t, b, "/tmp/a")
304
305	require.ErrorIs(t, b.DeleteWorkspace(ws.ID, ""), ErrInvalidClientID)
306	require.ErrorIs(t, b.DeleteWorkspace(ws.ID, "not-a-uuid"), ErrInvalidClientID)
307}
308
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.
312func TestHoldExpiry_RaceWithAttach(t *testing.T) {
313	t.Parallel()
314
315	for i := range 50 {
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")
320
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) }()
326		<-errCh
327
328		// Wait for any pending timer to settle.
329		time.Sleep(10 * time.Millisecond)
330
331		ws.clientsMu.Lock()
332		gotShutdown := shutdowns.Load() == 1
333		cs, present := ws.clients[cid]
334		var (
335			gotStreams   int
336			gotHoldTimer *time.Timer
337		)
338		if present {
339			gotStreams = cs.streams
340			gotHoldTimer = cs.holdTimer
341		}
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.
347		if gotShutdown {
348			require.False(t, present, "iter %d: shutdown but client still present", i)
349		} else {
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)
353		}
354	}
355}
356
357// TestConcurrentAttachDetach exercises the state machine under
358// parallel attach/detach pressure with the race detector.
359func TestConcurrentAttachDetach(t *testing.T) {
360	t.Parallel()
361
362	b, _ := newTestBackend(t)
363	ws, _ := insertTestWorkspace(t, b, "/tmp/a")
364
365	cid := newClientID(t)
366	b.registerClient(ws, cid)
367	require.NoError(t, b.AttachClient(ws.ID, cid)) // ensure refcount stays > 0.
368
369	const n = 50
370	var wg sync.WaitGroup
371	wg.Add(n)
372	for range n {
373		go func() {
374			defer wg.Done()
375			cid2 := newClientID(t)
376			_ = b.AttachClient(ws.ID, cid2)
377			b.DetachClient(ws.ID, cid2)
378		}()
379	}
380	wg.Wait()
381
382	ws.clientsMu.Lock()
383	defer ws.clientsMu.Unlock()
384	require.Len(t, ws.clients, 1)
385	require.Contains(t, ws.clients, cid)
386}
387
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.
391func 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())
396
397	cwd := t.TempDir()
398	dataDir := t.TempDir()
399
400	b := New(context.Background(), nil, func() {})
401	b.SetCreateGrace(2 * time.Second)
402	t.Cleanup(func() { drainBackend(t, b) })
403
404	cidA := uuid.New().String()
405	cidB := uuid.New().String()
406
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)
411
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)
416
417	wsA.clientsMu.Lock()
418	require.Contains(t, wsA.clients, cidA)
419	require.Contains(t, wsA.clients, cidB)
420	wsA.clientsMu.Unlock()
421}
422
423// TestPathDedupe_DifferentPaths_DifferentWorkspaces confirms that two
424// CreateWorkspace calls at distinct paths produce distinct workspaces.
425func 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())
430
431	cwdA := t.TempDir()
432	cwdB := t.TempDir()
433	dataA := t.TempDir()
434	dataB := t.TempDir()
435
436	b := New(context.Background(), nil, func() {})
437	b.SetCreateGrace(2 * time.Second)
438	t.Cleanup(func() { drainBackend(t, b) })
439
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)
445}
446
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
450// flags.
451func 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())
456
457	cwd := t.TempDir()
458	dataDir := t.TempDir()
459
460	b := New(context.Background(), nil, func() {})
461	b.SetCreateGrace(2 * time.Second)
462	t.Cleanup(func() { drainBackend(t, b) })
463
464	originalEnv := []string{"FOO=bar"}
465	argsA := protoWS(cwd, dataDir, uuid.New().String())
466	argsA.YOLO = true
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)
472
473	argsB := protoWS(cwd, dataDir, uuid.New().String())
474	argsB.YOLO = false
475	argsB.Debug = true
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)
483}
484
485// TestPathDedupe_Symlink confirms two paths that resolve to the same
486// target share a workspace.
487func TestPathDedupe_Symlink(t *testing.T) {
488	if runtime.GOOS == "windows" {
489		t.Skip("symlink semantics differ on windows")
490	}
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())
495
496	real := t.TempDir()
497	link := filepath.Join(t.TempDir(), "link")
498	require.NoError(t, os.Symlink(real, link))
499	dataDir := t.TempDir()
500
501	b := New(context.Background(), nil, func() {})
502	b.SetCreateGrace(2 * time.Second)
503	t.Cleanup(func() { drainBackend(t, b) })
504
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)
510}
511
512// TestPathDedupe_NonExistentPath ensures CreateWorkspace tolerates a
513// path that does not yet exist (EvalSymlinks falls back to Abs).
514func 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())
519
520	parent := t.TempDir()
521	missing := filepath.Join(parent, "does-not-exist")
522	dataDir := t.TempDir()
523
524	b := New(context.Background(), nil, func() {})
525	b.SetCreateGrace(2 * time.Second)
526	t.Cleanup(func() { drainBackend(t, b) })
527
528	_, p, err := b.CreateWorkspace(protoWS(missing, dataDir, uuid.New().String()))
529	require.NoError(t, err)
530	require.NotEmpty(t, p.ID)
531}
532
533// TestCreateWorkspace_IdempotentSameClient checks that a duplicate
534// create from the same client at the same path does not produce a
535// second claim.
536func 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())
541
542	cwd := 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) })
547
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)
554
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()
558}
559
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.
563func 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())
568
569	cwd := t.TempDir()
570	dataDir := t.TempDir()
571
572	b := New(context.Background(), nil, func() {})
573	b.SetCreateGrace(2 * time.Second)
574	t.Cleanup(func() { drainBackend(t, b) })
575
576	cidA := uuid.New().String()
577	cidB := uuid.New().String()
578
579	type result struct {
580		ws    *Workspace
581		proto proto.Workspace
582		err   error
583	}
584	ch := make(chan result, 2)
585	start := make(chan struct{})
586	go func() {
587		<-start
588		ws, p, err := b.CreateWorkspace(protoWS(cwd, dataDir, cidA))
589		ch <- result{ws, p, err}
590	}()
591	go func() {
592		<-start
593		ws, p, err := b.CreateWorkspace(protoWS(cwd, dataDir, cidB))
594		ch <- result{ws, p, err}
595	}()
596	close(start)
597	r1 := <-ch
598	r2 := <-ch
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")
602
603	ws := r1.ws
604	ws.clientsMu.Lock()
605	defer ws.clientsMu.Unlock()
606	require.Contains(t, ws.clients, cidA)
607	require.Contains(t, ws.clients, cidB)
608}
609
610// TestCreateWorkspace_RejectsBadClientID covers the 400 path from the
611// backend side.
612func TestCreateWorkspace_RejectsBadClientID(t *testing.T) {
613	t.Parallel()
614
615	b := New(context.Background(), nil, func() {})
616
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)
621}
622
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.
626func drainBackend(t *testing.T, b *Backend) {
627	t.Helper()
628	for _, ws := range b.workspaces.Seq2() {
629		ws.clientsMu.Lock()
630		ids := make([]string, 0, len(ws.clients))
631		for id := range ws.clients {
632			ids = append(ids, id)
633		}
634		ws.clientsMu.Unlock()
635		for _, cid := range ids {
636			_ = b.releaseHold(ws.ID, cid)
637		}
638	}
639}
640
641func protoWS(path, dataDir, clientID string) proto.Workspace {
642	return proto.Workspace{Path: path, DataDir: dataDir, ClientID: clientID}
643}
644
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.
648func captureDebugLogs(t *testing.T) *bytes.Buffer {
649	t.Helper()
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) })
655	return &buf
656}
657
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.
661func xdgIsolated(t *testing.T) {
662	t.Helper()
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())
667}
668
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
673// not overwritten.
674func TestFirstWinsMismatch_LogsOnFlagDifferences(t *testing.T) {
675	tests := []struct {
676		name   string
677		mutate func(*proto.Workspace)
678	}{
679		{
680			name:   "yolo",
681			mutate: func(p *proto.Workspace) { p.YOLO = true },
682		},
683		{
684			name:   "debug",
685			mutate: func(p *proto.Workspace) { p.Debug = true },
686		},
687		{
688			name:   "datadir",
689			mutate: func(p *proto.Workspace) { p.DataDir = "" },
690		},
691		{
692			name:   "env",
693			mutate: func(p *proto.Workspace) { p.Env = []string{"NEW=val"} },
694		},
695	}
696
697	for _, tc := range tests {
698		t.Run(tc.name, func(t *testing.T) {
699			xdgIsolated(t)
700			cwd := t.TempDir()
701			dataDir := t.TempDir()
702
703			buf := captureDebugLogs(t)
704			b := New(context.Background(), nil, func() {})
705			b.SetCreateGrace(2 * time.Second)
706			t.Cleanup(func() { drainBackend(t, b) })
707
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
714
715			argsB := protoWS(cwd, dataDir, uuid.New().String())
716			argsB.Env = []string{"FOO=bar"} // identical by default
717			tc.mutate(&argsB)
718			_, _, err = b.CreateWorkspace(argsB)
719			require.NoError(t, err)
720
721			require.Contains(
722				t, buf.String(),
723				"Workspace flag mismatch on duplicate create",
724				"expected debug log for mismatching %s", tc.name,
725			)
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")
729		})
730	}
731}
732
733// TestFirstWinsMismatch_NoLogWhenIdentical confirms identical args
734// do not emit the mismatch log line.
735func TestFirstWinsMismatch_NoLogWhenIdentical(t *testing.T) {
736	xdgIsolated(t)
737	cwd := t.TempDir()
738	dataDir := t.TempDir()
739
740	buf := captureDebugLogs(t)
741	b := New(context.Background(), nil, func() {})
742	b.SetCreateGrace(2 * time.Second)
743	t.Cleanup(func() { drainBackend(t, b) })
744
745	argsA := protoWS(cwd, dataDir, uuid.New().String())
746	argsA.Env = []string{"FOO=bar"}
747	_, _, err := b.CreateWorkspace(argsA)
748	require.NoError(t, err)
749
750	argsB := protoWS(cwd, dataDir, uuid.New().String())
751	argsB.Env = []string{"FOO=bar"}
752	_, _, err = b.CreateWorkspace(argsB)
753	require.NoError(t, err)
754
755	require.False(t,
756		strings.Contains(buf.String(), "Workspace flag mismatch on duplicate create"),
757		"identical args must not log a mismatch: %s", buf.String())
758}
759
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.
764func TestRaceTwoClientsAttachOneDetaches(t *testing.T) {
765	t.Parallel()
766
767	b, _ := newTestBackend(t)
768	ws, shutdowns := insertTestWorkspace(t, b, "/tmp/race-two")
769
770	cidA := newClientID(t)
771	cidB := newClientID(t)
772
773	var wg sync.WaitGroup
774	wg.Add(2)
775	go func() {
776		defer wg.Done()
777		require.NoError(t, b.AttachClient(ws.ID, cidA))
778	}()
779	go func() {
780		defer wg.Done()
781		require.NoError(t, b.AttachClient(ws.ID, cidB))
782	}()
783	wg.Wait()
784
785	ws.clientsMu.Lock()
786	require.Len(t, ws.clients, 2, "both clients must be attached")
787	ws.clientsMu.Unlock()
788
789	b.DetachClient(ws.ID, cidA)
790
791	ws.clientsMu.Lock()
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")
797
798	// Drain.
799	b.DetachClient(ws.ID, cidB)
800	require.Equal(t, int32(1), shutdowns.Load())
801}
802
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.
809func TestExplicitDeleteThenAttach(t *testing.T) {
810	t.Parallel()
811
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")
817
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))
822
823	cid := newClientID(t)
824	// Real hold via registerClient (mirrors CreateWorkspace).
825	b.registerClient(ws, cid)
826	ws.clientsMu.Lock()
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()
831
832	// releaseHold: consumes the hold and removes the entry
833	// (streams == 0). The anchor client keeps the workspace
834	// alive.
835	require.NoError(t, b.releaseHold(ws.ID, cid))
836	require.Equal(t, int32(0), shutdowns.Load(), "anchor must keep workspace alive")
837	ws.clientsMu.Lock()
838	require.NotContains(t, ws.clients, cid, "entry must be removed by releaseHold")
839	ws.clientsMu.Unlock()
840
841	// AttachClient creates a fresh entry with streams==1 and no
842	// hold timer.
843	require.NoError(t, b.AttachClient(ws.ID, cid))
844	ws.clientsMu.Lock()
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()
849
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))
853	ws.clientsMu.Lock()
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()
858
859	// Drain.
860	b.DetachClient(ws.ID, cid)
861	b.DetachClient(ws.ID, anchor)
862	require.Equal(t, int32(1), shutdowns.Load())
863}
864
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.
875func TestAttachClient_RacesWithTeardown(t *testing.T) {
876	t.Parallel()
877
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")
884
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))
890
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{})
897		go func() {
898			<-start
899			errCh <- b.AttachClient(ws.ID, cidB)
900		}()
901		go func() {
902			<-start
903			b.DetachClient(ws.ID, cidA)
904			close(detachDone)
905		}()
906		close(start)
907
908		// Wait for both goroutines so teardown (including
909		// shutdownFn) has fully run before we read state.
910		attachErr := <-errCh
911		<-detachDone
912
913		_, wsStillRegistered := b.workspaces.Get(ws.ID)
914		ws.clientsMu.Lock()
915		_, hasA := ws.clients[cidA]
916		_, hasB := ws.clients[cidB]
917		clientCount := len(ws.clients)
918		ws.clientsMu.Unlock()
919		shutdownCount := shutdowns.Load()
920
921		switch {
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)
949		default:
950			t.Fatalf("iter %d: unexpected AttachClient error: %v", i, attachErr)
951		}
952	}
953}