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}
 954
 955// TestSetCurrentSession_BasicAttachAndSwitch verifies the happy path:
 956// an attached client can set its current session, a second attached
 957// client can target the same session, and one of them can switch to a
 958// different session without disturbing the other's record.
 959func TestSetCurrentSession_BasicAttachAndSwitch(t *testing.T) {
 960	t.Parallel()
 961
 962	b, _ := newTestBackend(t)
 963	ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-basic")
 964
 965	cidA := newClientID(t)
 966	cidB := newClientID(t)
 967	require.NoError(t, b.AttachClient(ws.ID, cidA))
 968	require.NoError(t, b.AttachClient(ws.ID, cidB))
 969
 970	require.NoError(t, b.SetCurrentSession(ws.ID, cidA, "S1"))
 971	ws.clientsMu.Lock()
 972	require.Equal(t, "S1", ws.clients[cidA].currentSessionID)
 973	ws.clientsMu.Unlock()
 974
 975	require.NoError(t, b.SetCurrentSession(ws.ID, cidB, "S1"))
 976	ws.clientsMu.Lock()
 977	require.Equal(t, "S1", ws.clients[cidA].currentSessionID)
 978	require.Equal(t, "S1", ws.clients[cidB].currentSessionID)
 979	ws.clientsMu.Unlock()
 980
 981	// B switches to S2; counts redistribute.
 982	require.NoError(t, b.SetCurrentSession(ws.ID, cidB, "S2"))
 983	ws.clientsMu.Lock()
 984	require.Equal(t, "S1", ws.clients[cidA].currentSessionID)
 985	require.Equal(t, "S2", ws.clients[cidB].currentSessionID)
 986	ws.clientsMu.Unlock()
 987
 988	// A clears its selection.
 989	require.NoError(t, b.SetCurrentSession(ws.ID, cidA, ""))
 990	ws.clientsMu.Lock()
 991	require.Empty(t, ws.clients[cidA].currentSessionID)
 992	require.Equal(t, "S2", ws.clients[cidB].currentSessionID)
 993	ws.clientsMu.Unlock()
 994
 995	// Drain to release the workspace.
 996	b.DetachClient(ws.ID, cidA)
 997	b.DetachClient(ws.ID, cidB)
 998}
 999
1000// TestSetCurrentSession_DetachClearsEntry verifies the implicit
1001// cleanup: once a client's [clientState] entry is removed (last
1002// stream closed), its currentSessionID is gone with it.
1003func TestSetCurrentSession_DetachClearsEntry(t *testing.T) {
1004	t.Parallel()
1005
1006	b, _ := newTestBackend(t)
1007	ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-detach")
1008
1009	// Anchor client so the workspace is not torn down when cid
1010	// detaches.
1011	anchor := newClientID(t)
1012	require.NoError(t, b.AttachClient(ws.ID, anchor))
1013
1014	cid := newClientID(t)
1015	require.NoError(t, b.AttachClient(ws.ID, cid))
1016	require.NoError(t, b.SetCurrentSession(ws.ID, cid, "S2"))
1017
1018	b.DetachClient(ws.ID, cid)
1019
1020	ws.clientsMu.Lock()
1021	_, present := ws.clients[cid]
1022	ws.clientsMu.Unlock()
1023	require.False(t, present, "detach must remove the clientState entry along with its currentSessionID")
1024
1025	// A follow-up SetCurrentSession on the gone client must be
1026	// rejected with ErrClientNotAttached.
1027	require.ErrorIs(t, b.SetCurrentSession(ws.ID, cid, "S3"), ErrClientNotAttached)
1028
1029	b.DetachClient(ws.ID, anchor)
1030}
1031
1032// TestSetCurrentSession_RejectsHoldOnly verifies that a registered
1033// client whose only claim is a creation hold (streams == 0) cannot
1034// influence presence: SetCurrentSession returns ErrClientNotAttached
1035// and the entry's currentSessionID stays empty.
1036func TestSetCurrentSession_RejectsHoldOnly(t *testing.T) {
1037	t.Parallel()
1038
1039	b, _ := newTestBackend(t)
1040	// Keep the grace window large so the hold survives the test.
1041	b.createGrace = time.Hour
1042	ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-hold")
1043
1044	cid := newClientID(t)
1045	b.registerClient(ws, cid)
1046
1047	require.ErrorIs(t, b.SetCurrentSession(ws.ID, cid, "S1"), ErrClientNotAttached)
1048
1049	ws.clientsMu.Lock()
1050	require.Empty(t, ws.clients[cid].currentSessionID, "hold-only client must not write a session id")
1051	ws.clientsMu.Unlock()
1052
1053	// Drain.
1054	require.NoError(t, b.releaseHold(ws.ID, cid))
1055}
1056
1057// TestSetCurrentSession_UnknownClient verifies that a client with no
1058// entry at all is rejected with ErrClientNotAttached.
1059func TestSetCurrentSession_UnknownClient(t *testing.T) {
1060	t.Parallel()
1061
1062	b, _ := newTestBackend(t)
1063	ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-unknown")
1064
1065	require.ErrorIs(t, b.SetCurrentSession(ws.ID, newClientID(t), "S1"), ErrClientNotAttached)
1066}
1067
1068// TestSetCurrentSession_RejectsBadInputs covers the validation
1069// branches: empty/malformed client_id and unknown workspace.
1070func TestSetCurrentSession_RejectsBadInputs(t *testing.T) {
1071	t.Parallel()
1072
1073	b, _ := newTestBackend(t)
1074	ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-bad")
1075
1076	require.ErrorIs(t, b.SetCurrentSession(ws.ID, "", "S1"), ErrInvalidClientID)
1077	require.ErrorIs(t, b.SetCurrentSession(ws.ID, "not-a-uuid", "S1"), ErrInvalidClientID)
1078
1079	require.ErrorIs(
1080		t,
1081		b.SetCurrentSession("00000000-0000-0000-0000-000000000000", newClientID(t), "S1"),
1082		ErrWorkspaceNotFound,
1083	)
1084}
1085
1086// TestSetCurrentSession_RaceWithDetach exercises concurrent
1087// SetCurrentSession updates from one client racing against detach
1088// on a second client. The final state must be self-consistent: any
1089// remaining clientState entries reflect a coherent
1090// (streams, currentSessionID) pair.
1091func TestSetCurrentSession_RaceWithDetach(t *testing.T) {
1092	t.Parallel()
1093
1094	b, _ := newTestBackend(t)
1095	ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-race")
1096
1097	cidA := newClientID(t)
1098	cidB := newClientID(t)
1099	require.NoError(t, b.AttachClient(ws.ID, cidA))
1100	require.NoError(t, b.AttachClient(ws.ID, cidB))
1101
1102	var wg sync.WaitGroup
1103	const updates = 200
1104	wg.Add(3)
1105	go func() {
1106		defer wg.Done()
1107		for i := range updates {
1108			// Errors are tolerated: once cidA detaches,
1109			// further updates against cidA must return
1110			// ErrClientNotAttached but never panic.
1111			_ = b.SetCurrentSession(ws.ID, cidA, "SA")
1112			_ = i
1113		}
1114	}()
1115	go func() {
1116		defer wg.Done()
1117		for i := range updates {
1118			_ = b.SetCurrentSession(ws.ID, cidB, "SB")
1119			_ = i
1120		}
1121	}()
1122	go func() {
1123		defer wg.Done()
1124		// Single concurrent detach of cidA partway through.
1125		b.DetachClient(ws.ID, cidA)
1126	}()
1127	wg.Wait()
1128
1129	ws.clientsMu.Lock()
1130	defer ws.clientsMu.Unlock()
1131	require.NotContains(t, ws.clients, cidA, "detached client must be gone")
1132	require.Contains(t, ws.clients, cidB, "remaining client must still be present")
1133	require.Equal(t, "SB", ws.clients[cidB].currentSessionID, "remaining client must keep its last set session")
1134}
1135
1136// TestAttachedClients_BasicLifecycle walks one session's count through
1137// attach -> set -> second client joins -> switch -> detach. It also
1138// confirms hold-only and unselected clients do not contribute.
1139func TestAttachedClients_BasicLifecycle(t *testing.T) {
1140	t.Parallel()
1141
1142	b, _ := newTestBackend(t)
1143	// Keep the grace window long so the hold-only client survives.
1144	b.createGrace = time.Hour
1145	ws, _ := insertTestWorkspace(t, b, "/tmp/attached-clients-basic")
1146
1147	// No clients yet.
1148	n, err := b.AttachedClients(ws.ID, "S1")
1149	require.NoError(t, err)
1150	require.Zero(t, n)
1151
1152	// Attach A, set to S1. Count for S1 is 1; count for S2 is 0.
1153	cidA := newClientID(t)
1154	require.NoError(t, b.AttachClient(ws.ID, cidA))
1155	require.NoError(t, b.SetCurrentSession(ws.ID, cidA, "S1"))
1156
1157	n, err = b.AttachedClients(ws.ID, "S1")
1158	require.NoError(t, err)
1159	require.Equal(t, 1, n)
1160	n, err = b.AttachedClients(ws.ID, "S2")
1161	require.NoError(t, err)
1162	require.Zero(t, n)
1163
1164	// Attach B, set to S1. Count for S1 is 2.
1165	cidB := newClientID(t)
1166	require.NoError(t, b.AttachClient(ws.ID, cidB))
1167	require.NoError(t, b.SetCurrentSession(ws.ID, cidB, "S1"))
1168
1169	n, _ = b.AttachedClients(ws.ID, "S1")
1170	require.Equal(t, 2, n)
1171
1172	// B switches to S2; counts redistribute.
1173	require.NoError(t, b.SetCurrentSession(ws.ID, cidB, "S2"))
1174	n, _ = b.AttachedClients(ws.ID, "S1")
1175	require.Equal(t, 1, n)
1176	n, _ = b.AttachedClients(ws.ID, "S2")
1177	require.Equal(t, 1, n)
1178
1179	// A hold-only client must NOT be counted, even if we were able to
1180	// imagine a currentSessionID on it. registerClient leaves
1181	// currentSessionID empty by construction, and SetCurrentSession
1182	// rejects hold-only writers — so the contract holds two ways.
1183	cidHold := newClientID(t)
1184	b.registerClient(ws, cidHold)
1185	t.Cleanup(func() { _ = b.releaseHold(ws.ID, cidHold) })
1186	n, _ = b.AttachedClients(ws.ID, "S1")
1187	require.Equal(t, 1, n, "hold-only client must not contribute")
1188	n, _ = b.AttachedClients(ws.ID, "")
1189	require.Equal(t, 0, n,
1190		"empty sessionID must not match the hold-only entry (streams==0)")
1191
1192	// A client with streams > 0 but currentSessionID == "" is NOT
1193	// counted toward any non-empty session, and is matched only
1194	// against the empty session id (which represents the landing
1195	// screen).
1196	cidC := newClientID(t)
1197	require.NoError(t, b.AttachClient(ws.ID, cidC))
1198	n, _ = b.AttachedClients(ws.ID, "S1")
1199	require.Equal(t, 1, n, "stream-only client with empty currentSessionID must not be counted toward S1")
1200	n, _ = b.AttachedClients(ws.ID, "")
1201	require.Equal(t, 1, n, "stream-only client with empty currentSessionID matches the empty session id")
1202
1203	// B detaches: count for S2 drops to 0.
1204	b.DetachClient(ws.ID, cidB)
1205	n, _ = b.AttachedClients(ws.ID, "S2")
1206	require.Zero(t, n)
1207	n, _ = b.AttachedClients(ws.ID, "S1")
1208	require.Equal(t, 1, n, "A still on S1")
1209
1210	// Final cleanup.
1211	b.DetachClient(ws.ID, cidA)
1212	b.DetachClient(ws.ID, cidC)
1213}
1214
1215// TestAttachedClients_UnknownWorkspace verifies the error surface.
1216func TestAttachedClients_UnknownWorkspace(t *testing.T) {
1217	t.Parallel()
1218
1219	b, _ := newTestBackend(t)
1220	_, err := b.AttachedClients("00000000-0000-0000-0000-000000000000", "S1")
1221	require.ErrorIs(t, err, ErrWorkspaceNotFound)
1222}