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}