@@ -951,3 +951,184 @@ func TestAttachClient_RacesWithTeardown(t *testing.T) {
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) {
962+ b, _ := newTestBackend(t)
963+ ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-basic")
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))
970+ require.NoError(t, b.SetCurrentSession(ws.ID, cidA, "S1"))
972+ require.Equal(t, "S1", ws.clients[cidA].currentSessionID)
973+ ws.clientsMu.Unlock()
975+ require.NoError(t, b.SetCurrentSession(ws.ID, cidB, "S1"))
977+ require.Equal(t, "S1", ws.clients[cidA].currentSessionID)
978+ require.Equal(t, "S1", ws.clients[cidB].currentSessionID)
979+ ws.clientsMu.Unlock()
981+ // B switches to S2; counts redistribute.
982+ require.NoError(t, b.SetCurrentSession(ws.ID, cidB, "S2"))
984+ require.Equal(t, "S1", ws.clients[cidA].currentSessionID)
985+ require.Equal(t, "S2", ws.clients[cidB].currentSessionID)
986+ ws.clientsMu.Unlock()
988+ // A clears its selection.
989+ require.NoError(t, b.SetCurrentSession(ws.ID, cidA, ""))
991+ require.Empty(t, ws.clients[cidA].currentSessionID)
992+ require.Equal(t, "S2", ws.clients[cidB].currentSessionID)
993+ ws.clientsMu.Unlock()
995+ // Drain to release the workspace.
996+ b.DetachClient(ws.ID, cidA)
997+ b.DetachClient(ws.ID, cidB)
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) {
1006+ b, _ := newTestBackend(t)
1007+ ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-detach")
1009+ // Anchor client so the workspace is not torn down when cid
1011+ anchor := newClientID(t)
1012+ require.NoError(t, b.AttachClient(ws.ID, anchor))
1014+ cid := newClientID(t)
1015+ require.NoError(t, b.AttachClient(ws.ID, cid))
1016+ require.NoError(t, b.SetCurrentSession(ws.ID, cid, "S2"))
1018+ b.DetachClient(ws.ID, cid)
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")
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)
1029+ b.DetachClient(ws.ID, anchor)
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) {
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")
1044+ cid := newClientID(t)
1045+ b.registerClient(ws, cid)
1047+ require.ErrorIs(t, b.SetCurrentSession(ws.ID, cid, "S1"), ErrClientNotAttached)
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()
1054+ require.NoError(t, b.releaseHold(ws.ID, cid))
1057+// TestSetCurrentSession_UnknownClient verifies that a client with no
1058+// entry at all is rejected with ErrClientNotAttached.
1059+func TestSetCurrentSession_UnknownClient(t *testing.T) {
1062+ b, _ := newTestBackend(t)
1063+ ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-unknown")
1065+ require.ErrorIs(t, b.SetCurrentSession(ws.ID, newClientID(t), "S1"), ErrClientNotAttached)
1068+// TestSetCurrentSession_RejectsBadInputs covers the validation
1069+// branches: empty/malformed client_id and unknown workspace.
1070+func TestSetCurrentSession_RejectsBadInputs(t *testing.T) {
1073+ b, _ := newTestBackend(t)
1074+ ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-bad")
1076+ require.ErrorIs(t, b.SetCurrentSession(ws.ID, "", "S1"), ErrInvalidClientID)
1077+ require.ErrorIs(t, b.SetCurrentSession(ws.ID, "not-a-uuid", "S1"), ErrInvalidClientID)
1081+ b.SetCurrentSession("00000000-0000-0000-0000-000000000000", newClientID(t), "S1"),
1082+ ErrWorkspaceNotFound,
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) {
1094+ b, _ := newTestBackend(t)
1095+ ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-race")
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))
1102+ var wg sync.WaitGroup
1103+ const updates = 200
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")
1117+ for i := range updates {
1118+ _ = b.SetCurrentSession(ws.ID, cidB, "SB")
1124+ // Single concurrent detach of cidA partway through.
1125+ b.DetachClient(ws.ID, cidA)
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")