fix(profiles): connection-profile switch re-dials the WebSocket (#888)
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
546bd17b18
commit
683a24becf
10 files changed
+183
-17
No files matched your search
@@ -382,6 +382,11 @@ object AuthManager {
|
||||
ActiveSessionHolder.clear()
|
||||
}
|
||||
serverStore.update { it.copy(selectedProfileId = id) }
|
||||
// Keep contextFlow truthful: the base URL resolves per selected
|
||||
// profile, so a connection-profile switch must re-emit the NEW
|
||||
// server's URL (previously stale — reactive consumers saw the old
|
||||
// server's URL after a switch).
|
||||
_baseUrlFlow.value = serverStore.getLatestState().resolvedBaseUrl
|
||||
_selectedProfileFlow.value = id
|
||||
synchronized(this) {
|
||||
tokenInitialized = false
|
||||
@@ -416,7 +421,17 @@ object AuthManager {
|
||||
private fun normalizedProfileId(profileId: String?): String =
|
||||
profileId?.takeIf { it.isNotBlank() } ?: DEFAULT_PROFILE_ID
|
||||
|
||||
private suspend fun syncCookieStoreForProfile(profileId: String?) {
|
||||
/**
|
||||
* Swaps the cookie jar's ACTIVE store to the given profile's. Idempotent
|
||||
* (no-op when the store is already active). Suspends because switching
|
||||
* store reads/writes the encrypted prefs.
|
||||
*
|
||||
* Internal so the switch coordinator can AWAIT the swap before re-dialing
|
||||
* the WebSocket — a dial that races it mints the WS ticket with the
|
||||
* PREVIOUS server's cookie → 401 → dead socket (split-brain follow-up,
|
||||
* reproduced live 2026-08-12).
|
||||
*/
|
||||
internal suspend fun syncCookieStoreForProfile(profileId: String?) {
|
||||
if (!CookieManager.isInitialized()) return
|
||||
val normalizedId = normalizedProfileId(profileId)
|
||||
if (CookieManager.cookieJar.currentServer() != normalizedId) {
|
||||
@@ -456,6 +471,7 @@ object AuthManager {
|
||||
appScope = null
|
||||
}
|
||||
_activeProfileId.value = null
|
||||
_baseUrlFlow.value = ""
|
||||
}
|
||||
|
||||
fun getToken(): String? {
|
||||
|
||||
@@ -34,6 +34,9 @@ object ProfileSwitchCoordinator {
|
||||
private val _switched = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||
val switched: SharedFlow<String> = _switched.asSharedFlow()
|
||||
|
||||
private val _connectionSwitched = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||
val connectionSwitched: SharedFlow<String> = _connectionSwitched.asSharedFlow()
|
||||
|
||||
suspend fun switchProfile(name: String): NetworkResult<Unit> {
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -53,4 +56,40 @@ object ProfileSwitchCoordinator {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the CONNECTION profile — which server the app talks to (e.g.
|
||||
* LAN "default" vs a Tailscale host). Unlike [switchProfile] (which only
|
||||
* re-scopes the SERVER-side Hermes profile over the same socket), this
|
||||
* re-points Retrofit AND re-dials the WebSocket, because the socket stays
|
||||
* glued to the old server otherwise: after a switch every REST tab talks
|
||||
* to the new server while chat keeps streaming from the old gateway
|
||||
* (split-brain reproduced live 2026-08-12 on the hyari emulator).
|
||||
*
|
||||
* Order matters:
|
||||
* 1. Persist the LOCAL selection — the token cache, cookie scope and
|
||||
* [AuthManager.contextFlow] re-home to the new profile.
|
||||
* 2. Rebuild Retrofit so REST targets the new server.
|
||||
* 3. Emit [connectionSwitched] BEFORE the socket re-dial, so chat wipes
|
||||
* its stale conversation first; the re-dialed socket then delivers
|
||||
* gateway.ready → handleGatewayReady auto-creates a FRESH session on
|
||||
* the new server (desktop requestFreshSession parity).
|
||||
* 4. Re-dial the WebSocket off the main thread (the ticket mint does
|
||||
* blocking I/O — NetworkOnMainThreadException otherwise).
|
||||
*/
|
||||
suspend fun switchConnectionProfile(profileId: String?) {
|
||||
AuthManager.setSelectedProfileId(profileId)
|
||||
ApiClient.rebuild()
|
||||
_connectionSwitched.emit(profileId.orEmpty())
|
||||
withContext(Dispatchers.IO) {
|
||||
// The WS ticket mint reads the cookie jar's ACTIVE store; the
|
||||
// selection change swaps that store asynchronously, so a dial
|
||||
// that races it mints with the PREVIOUS server's cookie → 401 →
|
||||
// aborted socket with no retry. Await the swap before dialing
|
||||
// (idempotent no-op when it already landed).
|
||||
AuthManager.syncCookieStoreForProfile(profileId)
|
||||
HermesWsClient.disconnect()
|
||||
HermesWsClient.connect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -576,6 +576,16 @@ class ChatViewModel(
|
||||
resetSessionState(sessionId = null, title = "Hermes", isLoading = true)
|
||||
}
|
||||
}
|
||||
// Same wipe when the CONNECTION profile changes (different server):
|
||||
// without it, gateway.ready on the re-dialed socket tries to resume
|
||||
// the OLD server's session on the NEW server and fails (split-brain
|
||||
// after connection-profile switch, reproduced live 2026-08-12).
|
||||
viewModelScope.launch {
|
||||
ProfileSwitchCoordinator.connectionSwitched
|
||||
.collect { _ ->
|
||||
resetSessionState(sessionId = null, title = "Hermes", isLoading = true)
|
||||
}
|
||||
}
|
||||
// Slash-command usage ranking (issue #865): mirror the local usage
|
||||
// counts into state so the autocomplete can surface most-used
|
||||
// commands first. Best-effort — the store never throws.
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.m57.hermescontrol.data.remote.CleartextPolicy
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.ServerEndpoint
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.theme.ThemePreference
|
||||
import com.m57.hermescontrol.theme.ThemePreset
|
||||
@@ -103,9 +104,13 @@ class SettingsViewModel(
|
||||
}
|
||||
|
||||
fun selectProfile(profileId: String?) {
|
||||
AuthManager.setSelectedProfileId(profileId)
|
||||
viewModelScope.launch(ioDispatcher) { loadSettings() }
|
||||
ApiClient.rebuild()
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
// The coordinator re-homes EVERYTHING: selection + Retrofit +
|
||||
// WebSocket re-dial, so chat doesn't stay glued to the previous
|
||||
// server (issue: split-brain after connection-profile switch).
|
||||
ProfileSwitchCoordinator.switchConnectionProfile(profileId)
|
||||
loadSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteProfile(profileId: String) {
|
||||
@@ -114,14 +119,25 @@ class SettingsViewModel(
|
||||
AuthManager.setProfileToken(profileId, null)
|
||||
// Never leave selection null (issue #478): if the deleted profile was selected,
|
||||
// fall back to the default profile instead of clearing selection.
|
||||
if (AuthManager.getSelectedProfileId() == profileId) {
|
||||
if (updatedProfiles.none { it.id == AuthManager.DEFAULT_PROFILE_ID }) {
|
||||
AuthManager.ensureDefaultProfile()
|
||||
val reselectDefault =
|
||||
if (AuthManager.getSelectedProfileId() == profileId) {
|
||||
if (updatedProfiles.none { it.id == AuthManager.DEFAULT_PROFILE_ID }) {
|
||||
AuthManager.ensureDefaultProfile()
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
AuthManager.setSelectedProfileId(AuthManager.DEFAULT_PROFILE_ID)
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
if (reselectDefault) {
|
||||
// Same re-home as a manual switch: the WebSocket must leave
|
||||
// the deleted server too, or chat keeps its stale socket.
|
||||
ProfileSwitchCoordinator.switchConnectionProfile(AuthManager.DEFAULT_PROFILE_ID)
|
||||
} else {
|
||||
ApiClient.rebuild()
|
||||
}
|
||||
loadSettings()
|
||||
}
|
||||
viewModelScope.launch(ioDispatcher) { loadSettings() }
|
||||
ApiClient.rebuild()
|
||||
}
|
||||
|
||||
// ── Profile add/edit dialog ──────────────────────────────────────────
|
||||
|
||||
Reference in new issue
Block a user