Fix/issue 470 stuck reconnecting (#495)

* fix(ws): stop chat opening from racing the singleton into a stuck RECONNECTING

Root cause: HermesWsClient is a global singleton connected once at login;
every tab observes its single connectionStatus. ChatViewModel is recreated
on every chat-tab open and its init calls connectWebSocket() -> wsClient.connect().

connect() only guarded the CONNECTED case, so when the socket had dropped
(singleton in RECONNECTING with its own scheduled reconnect) opening chat
spawned a SECOND socket on the same webSocket field. The orphaned socket's
later onClosed set connected=false + re-armed RECONNECTING after the new
socket connected, and currentBackoff kept resetting on each chat open,
leaving the status stuck on RECONNECTING while every other tab (which never
calls connect()) stayed healthy.

Fix:
- HermesWsClient.connect() now no-ops while CONNECTING/RECONNECTING (in-flight
  socket already pending) and while AUTH_EXPIRED (needs re-auth, not a loop).
- ChatViewModel.connectWebSocket() mirrors the same terminal-state guard so
  the chat tab never disturbs an already-working or already-recovering
  connection. Behavior-preserving: happy path (CONNECTED) and genuine
  terminal states (DISCONNECTED/NO_NETWORK) still connect as before.

* fix(#470): sync cookie store scope with selected profile ID

Update AuthManager to switch CookieManager to the active server profile ID on startup and on runtime profile change.

* fix(#470): prevent stale token cache and load initial cookie scope

Pass active profile scope to CookieManager.initialize on startup and update setProfileToken to sync the in-memory cachedToken.

* feat(chat): add re-login action and credentials popup to connection banner

Add a Re-login button to the connection status banner and a dialog to prompt for credentials, allowing the user to refresh session cookies/tokens and reconnect the WebSocket.

* fix(chat): address PR #495 review feedback

* fix(chat): restore reconnect action in banner

* fix: resolve composable invocation in ReloginDialog

* fix(chat): enable re-login action during reconnecting state

Add the Re-login button to the connection status banner when connectionStatus is RECONNECTING, allowing users to re-authenticate and resolve stuck reconnect loops.
This commit is contained in:
M57 authored and GitHub committed 2026-07-08 01:36:23 +03:00
1 parent 739ff4f62b
commit 04bb7c4325
6 files changed
+319 -21

No files matched your search

@@ -119,7 +119,9 @@ object AuthManager {
// session-cookie prefs are passed as a Deferred so existing gated
// sessions can be migrated on first load WITHOUT blocking startup.
// The Deferred is created above, before this call.
CookieManager.initialize(context, prefsDeferred)
val initialProfileId =
store.getLatestState().selectedProfileId?.takeIf { it.isNotBlank() } ?: DEFAULT_PROFILE_ID
CookieManager.initialize(context, prefsDeferred, initialProfileId)
scope.launch {
store.stateFlow.collect { state ->
@@ -128,6 +130,8 @@ object AuthManager {
_useDynamicColorsFlow.value = state.useDynamicColors
_themePresetFlow.value = state.themePreset
_bottomNavDisplayModeFlow.value = state.bottomNavDisplayMode
// B7 (Jul 08 2026, kanban t_470): keep cookie scope aligned with active profile.
syncCookieStoreForProfile(state.selectedProfileId)
}
}
}
@@ -266,13 +270,15 @@ object AuthManager {
if (p.getBoolean(KEY_LEGACY_DEFAULT_MIGRATED, false)) return
val legacyToken = p.getString(KEY_LEGACY_TOKEN, null)
ensureDefaultProfile()
p.edit().apply {
if (!legacyToken.isNullOrBlank()) {
putString("token_$DEFAULT_PROFILE_ID", legacyToken)
}
remove(KEY_LEGACY_TOKEN)
putBoolean(KEY_LEGACY_DEFAULT_MIGRATED, true)
}.apply()
p
.edit()
.apply {
if (!legacyToken.isNullOrBlank()) {
putString("token_$DEFAULT_PROFILE_ID", legacyToken)
}
remove(KEY_LEGACY_TOKEN)
putBoolean(KEY_LEGACY_DEFAULT_MIGRATED, true)
}.apply()
}
// ── Pinned Models ────────────────────────────────────────────────────
@@ -291,6 +297,12 @@ object AuthManager {
) {
requirePrefs().edit().putString("token_$profileId", token).apply()
if (getSelectedProfileId() == profileId) {
// B7 (Jul 08 2026, kanban t_470): sync in-memory cachedToken
// to prevent stale tokens during ticket refresh
synchronized(this) {
cachedToken = token
tokenInitialized = true
}
_tokenFlow.value = token
}
}
@@ -306,6 +318,19 @@ object AuthManager {
tokenInitialized = false
}
_tokenFlow.value = getToken()
// B7 (Jul 08 2026, kanban t_470): keep cookie scope aligned with active profile.
syncCookieStoreForProfile(id)
}
private fun normalizedProfileId(profileId: String?): String =
profileId?.takeIf { it.isNotBlank() } ?: DEFAULT_PROFILE_ID
private fun syncCookieStoreForProfile(profileId: String?) {
if (!CookieManager.isInitialized()) return
val normalizedId = normalizedProfileId(profileId)
if (CookieManager.cookieJar.currentServer() != normalizedId) {
CookieManager.useStore(normalizedId)
}
}
// ── Token ────────────────────────────────────────────────────────────