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// syncBuffer is a thread-safe buffer that can be safely read and written
 646// from multiple goroutines.
 647type syncBuffer struct {
 648	mu  sync.Mutex
 649	buf bytes.Buffer
 650}
 651
 652func (sb *syncBuffer) Write(p []byte) (n int, err error) {
 653	sb.mu.Lock()
 654	defer sb.mu.Unlock()
 655	return sb.buf.Write(p)
 656}
 657
 658func (sb *syncBuffer) String() string {
 659	sb.mu.Lock()
 660	defer sb.mu.Unlock()
 661	return sb.buf.String()
 662}
 663
 664// captureDebugLogs installs a buffer-backed slog handler at Debug
 665// level for the duration of the test, returning the buffer. The
 666// previous default handler is restored via t.Cleanup.
 667func captureDebugLogs(t *testing.T) *syncBuffer {
 668	t.Helper()
 669	var sb syncBuffer
 670	prev := slog.Default()
 671	handler := slog.NewTextHandler(&sb, &slog.HandlerOptions{Level: slog.LevelDebug})
 672	slog.SetDefault(slog.New(handler))
 673	t.Cleanup(func() { slog.SetDefault(prev) })
 674	return &sb
 675}
 676
 677// xdgIsolated points HOME and XDG_* variables at fresh tempdirs so
 678// CreateWorkspace's config loading does not interfere with the host
 679// machine's real config.
 680func xdgIsolated(t *testing.T) {
 681	t.Helper()
 682	t.Setenv("HOME", t.TempDir())
 683	t.Setenv("XDG_CACHE_HOME", t.TempDir())
 684	t.Setenv("XDG_CONFIG_HOME", t.TempDir())
 685	t.Setenv("XDG_DATA_HOME", t.TempDir())
 686}
 687
 688// TestFirstWinsMismatch_LogsOnFlagDifferences verifies that the
 689// debug mismatch line is emitted when any of YOLO, Debug, DataDir,
 690// or Env differs between the first and second CreateWorkspace at
 691// the same path, and that the existing workspace's Debug flag is
 692// not overwritten.
 693func TestFirstWinsMismatch_LogsOnFlagDifferences(t *testing.T) {
 694	tests := []struct {
 695		name   string
 696		mutate func(*proto.Workspace)
 697	}{
 698		{
 699			name:   "yolo",
 700			mutate: func(p *proto.Workspace) { p.YOLO = true },
 701		},
 702		{
 703			name:   "debug",
 704			mutate: func(p *proto.Workspace) { p.Debug = true },
 705		},
 706		{
 707			name:   "datadir",
 708			mutate: func(p *proto.Workspace) { p.DataDir = "" },
 709		},
 710		{
 711			name:   "env",
 712			mutate: func(p *proto.Workspace) { p.Env = []string{"NEW=val"} },
 713		},
 714	}
 715
 716	for _, tc := range tests {
 717		t.Run(tc.name, func(t *testing.T) {
 718			xdgIsolated(t)
 719			cwd := t.TempDir()
 720			dataDir := t.TempDir()
 721
 722			buf := captureDebugLogs(t)
 723			b := New(context.Background(), nil, func() {})
 724			b.SetCreateGrace(2 * time.Second)
 725			t.Cleanup(func() { drainBackend(t, b) })
 726
 727			argsA := protoWS(cwd, dataDir, uuid.New().String())
 728			argsA.Env = []string{"FOO=bar"}
 729			wsA, _, err := b.CreateWorkspace(argsA)
 730			require.NoError(t, err)
 731			originalDebug := wsA.Cfg.Config().Options.Debug
 732			originalYOLO := wsA.Cfg.Overrides().SkipPermissionRequests
 733
 734			argsB := protoWS(cwd, dataDir, uuid.New().String())
 735			argsB.Env = []string{"FOO=bar"} // identical by default
 736			tc.mutate(&argsB)
 737			_, _, err = b.CreateWorkspace(argsB)
 738			require.NoError(t, err)
 739
 740			require.Contains(
 741				t, buf.String(),
 742				"Workspace flag mismatch on duplicate create",
 743				"expected debug log for mismatching %s", tc.name,
 744			)
 745			// Existing workspace's YOLO and Debug must not change.
 746			require.Equal(t, originalYOLO, wsA.Cfg.Overrides().SkipPermissionRequests, "YOLO must be immutable on first-wins")
 747			require.Equal(t, originalDebug, wsA.Cfg.Config().Options.Debug, "Debug must be immutable on first-wins")
 748		})
 749	}
 750}
 751
 752// TestFirstWinsMismatch_NoLogWhenIdentical confirms identical args
 753// do not emit the mismatch log line.
 754func TestFirstWinsMismatch_NoLogWhenIdentical(t *testing.T) {
 755	xdgIsolated(t)
 756	cwd := t.TempDir()
 757	dataDir := t.TempDir()
 758
 759	buf := captureDebugLogs(t)
 760	b := New(context.Background(), nil, func() {})
 761	b.SetCreateGrace(2 * time.Second)
 762	t.Cleanup(func() { drainBackend(t, b) })
 763
 764	argsA := protoWS(cwd, dataDir, uuid.New().String())
 765	argsA.Env = []string{"FOO=bar"}
 766	_, _, err := b.CreateWorkspace(argsA)
 767	require.NoError(t, err)
 768
 769	argsB := protoWS(cwd, dataDir, uuid.New().String())
 770	argsB.Env = []string{"FOO=bar"}
 771	_, _, err = b.CreateWorkspace(argsB)
 772	require.NoError(t, err)
 773
 774	require.False(t,
 775		strings.Contains(buf.String(), "Workspace flag mismatch on duplicate create"),
 776		"identical args must not log a mismatch: %s", buf.String())
 777}
 778
 779// TestRaceTwoClientsAttachOneDetaches exercises the PLAN-required
 780// race scenario: two clients attach concurrently, then one detaches.
 781// The workspace must remain alive with refcount==1 and the clients
 782// map must reflect the remaining client only.
 783func TestRaceTwoClientsAttachOneDetaches(t *testing.T) {
 784	t.Parallel()
 785
 786	b, _ := newTestBackend(t)
 787	ws, shutdowns := insertTestWorkspace(t, b, "/tmp/race-two")
 788
 789	cidA := newClientID(t)
 790	cidB := newClientID(t)
 791
 792	var wg sync.WaitGroup
 793	wg.Add(2)
 794	go func() {
 795		defer wg.Done()
 796		require.NoError(t, b.AttachClient(ws.ID, cidA))
 797	}()
 798	go func() {
 799		defer wg.Done()
 800		require.NoError(t, b.AttachClient(ws.ID, cidB))
 801	}()
 802	wg.Wait()
 803
 804	ws.clientsMu.Lock()
 805	require.Len(t, ws.clients, 2, "both clients must be attached")
 806	ws.clientsMu.Unlock()
 807
 808	b.DetachClient(ws.ID, cidA)
 809
 810	ws.clientsMu.Lock()
 811	require.Len(t, ws.clients, 1, "refcount must be 1 after one detach")
 812	require.Contains(t, ws.clients, cidB, "remaining client must be cidB")
 813	require.NotContains(t, ws.clients, cidA, "detached client must be removed")
 814	ws.clientsMu.Unlock()
 815	require.Equal(t, int32(0), shutdowns.Load(), "workspace must remain alive")
 816
 817	// Drain.
 818	b.DetachClient(ws.ID, cidB)
 819	require.Equal(t, int32(1), shutdowns.Load())
 820}
 821
 822// TestExplicitDeleteThenAttach reproduces the PLAN scenario: start
 823// with a real hold, releaseHold consumes it, AttachClient from the
 824// same clientID creates a fresh entry with streams==1, and calling
 825// releaseHold again is a no-op. A second client keeps the workspace
 826// alive so AttachClient can still resolve the workspace ID after the
 827// first client's hold is released.
 828func TestExplicitDeleteThenAttach(t *testing.T) {
 829	t.Parallel()
 830
 831	// Large grace window so timers cannot fire during the test
 832	// — we want to exercise the explicit releaseHold path.
 833	b, _ := newTestBackend(t)
 834	b.createGrace = time.Hour
 835	ws, shutdowns := insertTestWorkspace(t, b, "/tmp/delete-then-attach")
 836
 837	// Anchor client keeps the workspace registered in
 838	// b.workspaces across the cid's releaseHold below.
 839	anchor := newClientID(t)
 840	require.NoError(t, b.AttachClient(ws.ID, anchor))
 841
 842	cid := newClientID(t)
 843	// Real hold via registerClient (mirrors CreateWorkspace).
 844	b.registerClient(ws, cid)
 845	ws.clientsMu.Lock()
 846	require.Contains(t, ws.clients, cid)
 847	require.NotNil(t, ws.clients[cid].holdTimer, "hold must be live")
 848	require.Equal(t, 0, ws.clients[cid].streams)
 849	ws.clientsMu.Unlock()
 850
 851	// releaseHold: consumes the hold and removes the entry
 852	// (streams == 0). The anchor client keeps the workspace
 853	// alive.
 854	require.NoError(t, b.releaseHold(ws.ID, cid))
 855	require.Equal(t, int32(0), shutdowns.Load(), "anchor must keep workspace alive")
 856	ws.clientsMu.Lock()
 857	require.NotContains(t, ws.clients, cid, "entry must be removed by releaseHold")
 858	ws.clientsMu.Unlock()
 859
 860	// AttachClient creates a fresh entry with streams==1 and no
 861	// hold timer.
 862	require.NoError(t, b.AttachClient(ws.ID, cid))
 863	ws.clientsMu.Lock()
 864	require.Contains(t, ws.clients, cid, "fresh entry must be created")
 865	require.Equal(t, 1, ws.clients[cid].streams, "fresh attach must start at streams=1")
 866	require.Nil(t, ws.clients[cid].holdTimer, "fresh attach must have no hold timer")
 867	ws.clientsMu.Unlock()
 868
 869	// Calling releaseHold again is a no-op (no hold timer to
 870	// stop, streams > 0 so the entry stays).
 871	require.NoError(t, b.releaseHold(ws.ID, cid))
 872	ws.clientsMu.Lock()
 873	require.Contains(t, ws.clients, cid, "releaseHold must not touch a stream-only entry")
 874	require.Equal(t, 1, ws.clients[cid].streams)
 875	require.Nil(t, ws.clients[cid].holdTimer)
 876	ws.clientsMu.Unlock()
 877
 878	// Drain.
 879	b.DetachClient(ws.ID, cid)
 880	b.DetachClient(ws.ID, anchor)
 881	require.Equal(t, int32(1), shutdowns.Load())
 882}
 883
 884// TestAttachClient_RacesWithTeardown forces AttachClient to compete
 885// with the teardown path triggered by DetachClient. Before the fix,
 886// AttachClient could observe a workspace after teardown had already
 887// decided to remove it (because AttachClient did not synchronize with
 888// Backend.mu), leaving a live stream claim attached to a workspace
 889// that was then removed and shut down. With the fix, the outcome must
 890// be deterministic: either AttachClient won and the workspace is
 891// alive with the client registered, or teardown won and AttachClient
 892// returns ErrWorkspaceNotFound — never a half-state where the
 893// workspace is gone but ws.clients still contains the new client.
 894func TestAttachClient_RacesWithTeardown(t *testing.T) {
 895	t.Parallel()
 896
 897	for i := range 200 {
 898		b, _ := newTestBackend(t)
 899		// Keep the grace window long so it can't fire during the
 900		// test and confuse the bookkeeping.
 901		b.createGrace = time.Hour
 902		ws, shutdowns := insertTestWorkspace(t, b, "/tmp/race-teardown")
 903
 904		// Seed: cidA holds the workspace open via a stream. The
 905		// imminent DetachClient(cidA) will be the *only* claim
 906		// drop, so teardown will run.
 907		cidA := newClientID(t)
 908		require.NoError(t, b.AttachClient(ws.ID, cidA))
 909
 910		// cidB attempts to attach concurrently with the detach
 911		// that will tear the workspace down.
 912		cidB := newClientID(t)
 913		start := make(chan struct{})
 914		errCh := make(chan error, 1)
 915		detachDone := make(chan struct{})
 916		go func() {
 917			<-start
 918			errCh <- b.AttachClient(ws.ID, cidB)
 919		}()
 920		go func() {
 921			<-start
 922			b.DetachClient(ws.ID, cidA)
 923			close(detachDone)
 924		}()
 925		close(start)
 926
 927		// Wait for both goroutines so teardown (including
 928		// shutdownFn) has fully run before we read state.
 929		attachErr := <-errCh
 930		<-detachDone
 931
 932		_, wsStillRegistered := b.workspaces.Get(ws.ID)
 933		ws.clientsMu.Lock()
 934		_, hasA := ws.clients[cidA]
 935		_, hasB := ws.clients[cidB]
 936		clientCount := len(ws.clients)
 937		ws.clientsMu.Unlock()
 938		shutdownCount := shutdowns.Load()
 939
 940		switch {
 941		case attachErr == nil:
 942			// AttachClient won. The workspace must be alive
 943			// (registered) with cidB in its clients map. cidA
 944			// may or may not still be there depending on who
 945			// took clientsMu first, but the workspace must
 946			// not have been torn down.
 947			require.True(t, wsStillRegistered,
 948				"iter %d: attach succeeded but workspace was removed", i)
 949			require.True(t, hasB,
 950				"iter %d: attach succeeded but cidB missing from clients", i)
 951			require.Equal(t, int32(0), shutdownCount,
 952				"iter %d: attach succeeded but workspace was shut down", i)
 953		case errors.Is(attachErr, ErrWorkspaceNotFound):
 954			// Teardown won. The workspace must be removed,
 955			// shut down exactly once, and ws.clients must be
 956			// empty (no half-state with cidB inserted into a
 957			// dead workspace's clients map).
 958			require.False(t, wsStillRegistered,
 959				"iter %d: ErrWorkspaceNotFound but workspace still registered", i)
 960			require.Equal(t, int32(1), shutdownCount,
 961				"iter %d: ErrWorkspaceNotFound but shutdown count = %d", i, shutdownCount)
 962			require.False(t, hasA,
 963				"iter %d: teardown won but cidA still in clients", i)
 964			require.False(t, hasB,
 965				"iter %d: teardown won but cidB still in clients (would be the leaked attach)", i)
 966			require.Zero(t, clientCount,
 967				"iter %d: teardown won but clients map is non-empty", i)
 968		default:
 969			t.Fatalf("iter %d: unexpected AttachClient error: %v", i, attachErr)
 970		}
 971	}
 972}
 973
 974// TestSetCurrentSession_BasicAttachAndSwitch verifies the happy path:
 975// an attached client can set its current session, a second attached
 976// client can target the same session, and one of them can switch to a
 977// different session without disturbing the other's record.
 978func TestSetCurrentSession_BasicAttachAndSwitch(t *testing.T) {
 979	t.Parallel()
 980
 981	b, _ := newTestBackend(t)
 982	ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-basic")
 983
 984	cidA := newClientID(t)
 985	cidB := newClientID(t)
 986	require.NoError(t, b.AttachClient(ws.ID, cidA))
 987	require.NoError(t, b.AttachClient(ws.ID, cidB))
 988
 989	require.NoError(t, b.SetCurrentSession(ws.ID, cidA, "S1"))
 990	ws.clientsMu.Lock()
 991	require.Equal(t, "S1", ws.clients[cidA].currentSessionID)
 992	ws.clientsMu.Unlock()
 993
 994	require.NoError(t, b.SetCurrentSession(ws.ID, cidB, "S1"))
 995	ws.clientsMu.Lock()
 996	require.Equal(t, "S1", ws.clients[cidA].currentSessionID)
 997	require.Equal(t, "S1", ws.clients[cidB].currentSessionID)
 998	ws.clientsMu.Unlock()
 999
1000	// B switches to S2; counts redistribute.
1001	require.NoError(t, b.SetCurrentSession(ws.ID, cidB, "S2"))
1002	ws.clientsMu.Lock()
1003	require.Equal(t, "S1", ws.clients[cidA].currentSessionID)
1004	require.Equal(t, "S2", ws.clients[cidB].currentSessionID)
1005	ws.clientsMu.Unlock()
1006
1007	// A clears its selection.
1008	require.NoError(t, b.SetCurrentSession(ws.ID, cidA, ""))
1009	ws.clientsMu.Lock()
1010	require.Empty(t, ws.clients[cidA].currentSessionID)
1011	require.Equal(t, "S2", ws.clients[cidB].currentSessionID)
1012	ws.clientsMu.Unlock()
1013
1014	// Drain to release the workspace.
1015	b.DetachClient(ws.ID, cidA)
1016	b.DetachClient(ws.ID, cidB)
1017}
1018
1019// TestSetCurrentSession_DetachClearsEntry verifies the implicit
1020// cleanup: once a client's [clientState] entry is removed (last
1021// stream closed), its currentSessionID is gone with it.
1022func TestSetCurrentSession_DetachClearsEntry(t *testing.T) {
1023	t.Parallel()
1024
1025	b, _ := newTestBackend(t)
1026	ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-detach")
1027
1028	// Anchor client so the workspace is not torn down when cid
1029	// detaches.
1030	anchor := newClientID(t)
1031	require.NoError(t, b.AttachClient(ws.ID, anchor))
1032
1033	cid := newClientID(t)
1034	require.NoError(t, b.AttachClient(ws.ID, cid))
1035	require.NoError(t, b.SetCurrentSession(ws.ID, cid, "S2"))
1036
1037	b.DetachClient(ws.ID, cid)
1038
1039	ws.clientsMu.Lock()
1040	_, present := ws.clients[cid]
1041	ws.clientsMu.Unlock()
1042	require.False(t, present, "detach must remove the clientState entry along with its currentSessionID")
1043
1044	// A follow-up SetCurrentSession on the gone client must be
1045	// rejected with ErrClientNotAttached.
1046	require.ErrorIs(t, b.SetCurrentSession(ws.ID, cid, "S3"), ErrClientNotAttached)
1047
1048	b.DetachClient(ws.ID, anchor)
1049}
1050
1051// TestSetCurrentSession_RejectsHoldOnly verifies that a registered
1052// client whose only claim is a creation hold (streams == 0) cannot
1053// influence presence: SetCurrentSession returns ErrClientNotAttached
1054// and the entry's currentSessionID stays empty.
1055func TestSetCurrentSession_RejectsHoldOnly(t *testing.T) {
1056	t.Parallel()
1057
1058	b, _ := newTestBackend(t)
1059	// Keep the grace window large so the hold survives the test.
1060	b.createGrace = time.Hour
1061	ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-hold")
1062
1063	cid := newClientID(t)
1064	b.registerClient(ws, cid)
1065
1066	require.ErrorIs(t, b.SetCurrentSession(ws.ID, cid, "S1"), ErrClientNotAttached)
1067
1068	ws.clientsMu.Lock()
1069	require.Empty(t, ws.clients[cid].currentSessionID, "hold-only client must not write a session id")
1070	ws.clientsMu.Unlock()
1071
1072	// Drain.
1073	require.NoError(t, b.releaseHold(ws.ID, cid))
1074}
1075
1076// TestSetCurrentSession_UnknownClient verifies that a client with no
1077// entry at all is rejected with ErrClientNotAttached.
1078func TestSetCurrentSession_UnknownClient(t *testing.T) {
1079	t.Parallel()
1080
1081	b, _ := newTestBackend(t)
1082	ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-unknown")
1083
1084	require.ErrorIs(t, b.SetCurrentSession(ws.ID, newClientID(t), "S1"), ErrClientNotAttached)
1085}
1086
1087// TestSetCurrentSession_RejectsBadInputs covers the validation
1088// branches: empty/malformed client_id and unknown workspace.
1089func TestSetCurrentSession_RejectsBadInputs(t *testing.T) {
1090	t.Parallel()
1091
1092	b, _ := newTestBackend(t)
1093	ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-bad")
1094
1095	require.ErrorIs(t, b.SetCurrentSession(ws.ID, "", "S1"), ErrInvalidClientID)
1096	require.ErrorIs(t, b.SetCurrentSession(ws.ID, "not-a-uuid", "S1"), ErrInvalidClientID)
1097
1098	require.ErrorIs(
1099		t,
1100		b.SetCurrentSession("00000000-0000-0000-0000-000000000000", newClientID(t), "S1"),
1101		ErrWorkspaceNotFound,
1102	)
1103}
1104
1105// TestSetCurrentSession_RaceWithDetach exercises concurrent
1106// SetCurrentSession updates from one client racing against detach
1107// on a second client. The final state must be self-consistent: any
1108// remaining clientState entries reflect a coherent
1109// (streams, currentSessionID) pair.
1110func TestSetCurrentSession_RaceWithDetach(t *testing.T) {
1111	t.Parallel()
1112
1113	b, _ := newTestBackend(t)
1114	ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-race")
1115
1116	cidA := newClientID(t)
1117	cidB := newClientID(t)
1118	require.NoError(t, b.AttachClient(ws.ID, cidA))
1119	require.NoError(t, b.AttachClient(ws.ID, cidB))
1120
1121	var wg sync.WaitGroup
1122	const updates = 200
1123	wg.Add(3)
1124	go func() {
1125		defer wg.Done()
1126		for i := range updates {
1127			// Errors are tolerated: once cidA detaches,
1128			// further updates against cidA must return
1129			// ErrClientNotAttached but never panic.
1130			_ = b.SetCurrentSession(ws.ID, cidA, "SA")
1131			_ = i
1132		}
1133	}()
1134	go func() {
1135		defer wg.Done()
1136		for i := range updates {
1137			_ = b.SetCurrentSession(ws.ID, cidB, "SB")
1138			_ = i
1139		}
1140	}()
1141	go func() {
1142		defer wg.Done()
1143		// Single concurrent detach of cidA partway through.
1144		b.DetachClient(ws.ID, cidA)
1145	}()
1146	wg.Wait()
1147
1148	ws.clientsMu.Lock()
1149	defer ws.clientsMu.Unlock()
1150	require.NotContains(t, ws.clients, cidA, "detached client must be gone")
1151	require.Contains(t, ws.clients, cidB, "remaining client must still be present")
1152	require.Equal(t, "SB", ws.clients[cidB].currentSessionID, "remaining client must keep its last set session")
1153}
1154
1155// TestAttachedClients_BasicLifecycle walks one session's count through
1156// attach -> set -> second client joins -> switch -> detach. It also
1157// confirms hold-only and unselected clients do not contribute.
1158func TestAttachedClients_BasicLifecycle(t *testing.T) {
1159	t.Parallel()
1160
1161	b, _ := newTestBackend(t)
1162	// Keep the grace window long so the hold-only client survives.
1163	b.createGrace = time.Hour
1164	ws, _ := insertTestWorkspace(t, b, "/tmp/attached-clients-basic")
1165
1166	// No clients yet.
1167	n, err := b.AttachedClients(ws.ID, "S1")
1168	require.NoError(t, err)
1169	require.Zero(t, n)
1170
1171	// Attach A, set to S1. Count for S1 is 1; count for S2 is 0.
1172	cidA := newClientID(t)
1173	require.NoError(t, b.AttachClient(ws.ID, cidA))
1174	require.NoError(t, b.SetCurrentSession(ws.ID, cidA, "S1"))
1175
1176	n, err = b.AttachedClients(ws.ID, "S1")
1177	require.NoError(t, err)
1178	require.Equal(t, 1, n)
1179	n, err = b.AttachedClients(ws.ID, "S2")
1180	require.NoError(t, err)
1181	require.Zero(t, n)
1182
1183	// Attach B, set to S1. Count for S1 is 2.
1184	cidB := newClientID(t)
1185	require.NoError(t, b.AttachClient(ws.ID, cidB))
1186	require.NoError(t, b.SetCurrentSession(ws.ID, cidB, "S1"))
1187
1188	n, _ = b.AttachedClients(ws.ID, "S1")
1189	require.Equal(t, 2, n)
1190
1191	// B switches to S2; counts redistribute.
1192	require.NoError(t, b.SetCurrentSession(ws.ID, cidB, "S2"))
1193	n, _ = b.AttachedClients(ws.ID, "S1")
1194	require.Equal(t, 1, n)
1195	n, _ = b.AttachedClients(ws.ID, "S2")
1196	require.Equal(t, 1, n)
1197
1198	// A hold-only client must NOT be counted, even if we were able to
1199	// imagine a currentSessionID on it. registerClient leaves
1200	// currentSessionID empty by construction, and SetCurrentSession
1201	// rejects hold-only writers — so the contract holds two ways.
1202	cidHold := newClientID(t)
1203	b.registerClient(ws, cidHold)
1204	t.Cleanup(func() { _ = b.releaseHold(ws.ID, cidHold) })
1205	n, _ = b.AttachedClients(ws.ID, "S1")
1206	require.Equal(t, 1, n, "hold-only client must not contribute")
1207	n, _ = b.AttachedClients(ws.ID, "")
1208	require.Equal(t, 0, n,
1209		"empty sessionID must not match the hold-only entry (streams==0)")
1210
1211	// A client with streams > 0 but currentSessionID == "" is NOT
1212	// counted toward any non-empty session, and is matched only
1213	// against the empty session id (which represents the landing
1214	// screen).
1215	cidC := newClientID(t)
1216	require.NoError(t, b.AttachClient(ws.ID, cidC))
1217	n, _ = b.AttachedClients(ws.ID, "S1")
1218	require.Equal(t, 1, n, "stream-only client with empty currentSessionID must not be counted toward S1")
1219	n, _ = b.AttachedClients(ws.ID, "")
1220	require.Equal(t, 1, n, "stream-only client with empty currentSessionID matches the empty session id")
1221
1222	// B detaches: count for S2 drops to 0.
1223	b.DetachClient(ws.ID, cidB)
1224	n, _ = b.AttachedClients(ws.ID, "S2")
1225	require.Zero(t, n)
1226	n, _ = b.AttachedClients(ws.ID, "S1")
1227	require.Equal(t, 1, n, "A still on S1")
1228
1229	// Final cleanup.
1230	b.DetachClient(ws.ID, cidA)
1231	b.DetachClient(ws.ID, cidC)
1232}
1233
1234// TestAttachedClients_UnknownWorkspace verifies the error surface.
1235func TestAttachedClients_UnknownWorkspace(t *testing.T) {
1236	t.Parallel()
1237
1238	b, _ := newTestBackend(t)
1239	_, err := b.AttachedClients("00000000-0000-0000-0000-000000000000", "S1")
1240	require.ErrorIs(t, err, ErrWorkspaceNotFound)
1241}