@@ -951,3 +951,184 @@ func TestAttachClient_RacesWithTeardown(t *testing.T) {
951951 }
952952 }
953953}
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.
959+func 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.
1003+func 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.
1036+func 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.
1059+func 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.
1070+func 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.
1091+func 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+}