Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ class WebsocketManager(appContext: Context, workerParams: WorkerParameters) :
// play ping pong to ensure we have a connection and server changes are handled.
do {
delay(30000)
} while (jobs.values.any { it.isActive } && isActive && shouldWeRun() && manageServerJobs(jobs, this))
} while (isActive && shouldWeRun() && manageServerJobs(jobs, this))

jobs.forEach { it.value.cancel() }
jobs.clear()
Expand Down Expand Up @@ -176,9 +176,9 @@ class WebsocketManager(appContext: Context, workerParams: WorkerParameters) :
private suspend fun manageServerJobs(jobs: MutableMap<Int, Job>, coroutineScope: CoroutineScope): Boolean {
val servers = serverManager.servers()

// Clean up...
jobs.filter { (serverId, _) ->
servers.none { it.id == serverId } || !shouldRunForServer(serverId)
// Clean up, including stopped jobs so they are started again below...
jobs.filter { (serverId, job) ->
servers.none { it.id == serverId } || !job.isActive || !shouldRunForServer(serverId)
}
.forEach { (serverId, job) ->
job.cancel()
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/res/xml/changelog_master.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@
tools:ignore="MissingDefaultResource">
<release version="2026.7.2 - Main" versioncode="3">
<change>Android 17: added assistant volume level sensor + notification command for control</change>
<change>Local push notifications now detect silently dropped connections and keep retrying to reconnect while the server is unreachable, like during a server restart, instead of staying disconnected</change>
Comment thread
markfrancisonly marked this conversation as resolved.
Outdated
<change>Bug fixes and dependency updates</change>
</release>
<release version="2026.7.2 - Wear" versioncode="2">
<change>Android 17: added assistant volume level sensor</change>
<change>Local push notifications now detect silently dropped connections and keep retrying to reconnect while the server is unreachable, like during a server restart, instead of staying disconnected</change>
<change>Bug fixes and dependency updates</change>
</release>
<release version="2026.7.2 - Automotive" versioncode="1">
<change>Android 17: added assistant volume level sensor + notification command for control</change>
<change>Local push notifications now detect silently dropped connections and keep retrying to reconnect while the server is unreachable, like during a server restart, instead of staying disconnected</change>
<change>Bug fixes and dependency updates</change>
</release>
</changelog>
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,17 @@ internal interface WebSocketCore {
*/
suspend fun sendBytes(data: ByteArray): Boolean

/**
* Sends an application-level ping and verifies that the connection is alive.
*
* If no pong is received while the connection is believed to be established, the connection is
* cancelled: this surfaces a silently dropped socket (e.g. the server restarted without the
* TCP connection being reset) to the close handling, which restores active subscriptions.
Comment thread
TimoPtr marked this conversation as resolved.
Outdated
*
* @return `true` if a pong was received, `false` otherwise.
*/
suspend fun ping(): Boolean

/**
* Start a subscription for events on the websocket connection and get a Flow for listening to
* new messages. When there are no more listeners, the subscription will automatically be cancelled
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
import kotlin.coroutines.cancellation.CancellationException
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineExceptionHandler
Expand Down Expand Up @@ -99,6 +100,9 @@ import timber.log.Timber

private val DELAY_BEFORE_RECONNECT = 10.seconds

/** Upper bound for the backoff between reconnection attempts while the server stays unreachable. */
private val MAX_DELAY_BEFORE_RECONNECT = 2.minutes

/**
* Implementation of the [WebSocketCore] interface for managing WebSocket connections to a Home Assistant server.
*
Expand Down Expand Up @@ -182,6 +186,9 @@ internal class WebSocketCoreImpl(
@Volatile
private var pendingCloseReason: WebSocketState.Closed.Reason? = null

/** The running subscription restore loop, replaced on each closing so only one runs at a time. */
private var reconnectJob: Job? = null

/**
* A [CompletableDeferred] that signals the establishment and authentication of a WebSocket connection.
*
Expand Down Expand Up @@ -622,6 +629,26 @@ internal class WebSocketCoreImpl(
}
}

override suspend fun ping(): Boolean {
// Capture the connection this ping verifies, so a connection (re)established while
// waiting for the pong is never cancelled by mistake.
val holder = connectionHolder.get()
val response = sendMessage(mapOf("type" to "ping"))
if (response is PongSocketResponse) return true

if (holder != null && connectionHolder.get() === holder) {
// No pong on a connection that is believed to be established means the socket is
// half-open: the server went away without the TCP connection being reset, for
// example when it restarted and its address moved. OkHttp keeps buffering writes
// into such a socket without ever failing, so no close callback would fire on its
// own. Cancelling forces onFailure, which runs handleClosingSocket and lets the
// restore loop bring the connection and its subscriptions back.
Timber.w("No pong received on the established connection, cancelling it to trigger reconnection")
holder.webSocket.cancel()
}
return false
}

override suspend fun <T : Any> subscribeTo(
type: String,
data: Map<String, Any?>,
Expand Down Expand Up @@ -765,16 +792,18 @@ internal class WebSocketCoreImpl(
awaitClose {
wsScope.launch {
eventSubscriptionMutex.withLock {
findSubscription(subscribeMessage)
?.let {
val subscription = it.key
Timber.d("Unsubscribing from $subscribeMessage")
// Unsubscribe must happen before removing from activeMessages to ensure
// the server acknowledges before we stop handling events for this subscription
unsubscribeEvents(subscription)
channel.close()
activeMessages.remove(subscription)
}
// Resubscribing briefly tracks two entries for the same message, remove
Comment thread
markfrancisonly marked this conversation as resolved.
Outdated
// them all so no orphan keeps the connection alive
var subscription = findSubscription(subscribeMessage)?.key
while (subscription != null) {
Timber.d("Unsubscribing from $subscribeMessage")
// Unsubscribe must happen before removing from activeMessages to ensure
// the server acknowledges before we stop handling events for this subscription
unsubscribeEvents(subscription)
activeMessages.remove(subscription)
subscription = findSubscription(subscribeMessage)?.key
}
channel.close()
}
if (activeMessages.isEmpty()) {
Timber.i("No more subscriptions, closing connection.")
Expand Down Expand Up @@ -1022,52 +1051,82 @@ internal class WebSocketCoreImpl(
if (connectionHolder.get() == null) return

wsScope.launch {
val hasSubscriptions: Boolean

connectedMutex.withLock {
Comment thread
markfrancisonly marked this conversation as resolved.
val holder = connectionHolder.getAndSet(null)
// Another callback of the same closing already handled it
val holder = connectionHolder.getAndSet(null) ?: return@launch
Comment thread
markfrancisonly marked this conversation as resolved.
// Cancel URL observer - connect() will recreate it if needed
holder?.urlObserverJob?.cancel()
holder.urlObserverJob.cancel()

cleanupClosingSocket()
hasSubscriptions = activeMessages.any { it.value is ActiveMessage.Subscription }
}

val shouldAttemptReconnect = hasSubscriptions && wasActive

if (shouldAttemptReconnect && wsScope.isActive) {
// Delay before reconnect unless URL changed
if (closeReason != WebSocketState.Closed.Reason.CHANGED_URL) {
delay(DELAY_BEFORE_RECONNECT)
val hasSubscriptions = activeMessages.any { it.value is ActiveMessage.Subscription }

if (hasSubscriptions && wasActive && wsScope.isActive) {
Comment thread
markfrancisonly marked this conversation as resolved.
Outdated
// A new closing takes over the restore loop with a fresh snapshot, replacing
// the job under the lock so concurrent closings cannot start two loops
reconnectJob?.cancel()
reconnectJob = wsScope.launch {
// Delay before reconnect unless URL changed
if (closeReason != WebSocketState.Closed.Reason.CHANGED_URL) {
delay(DELAY_BEFORE_RECONNECT)
}
reconnectSubscriptions()
}
}
reconnectSubscriptions()
}
}
}

private suspend fun reconnectSubscriptions() {
if (connect()) {
Timber.d("Resubscribing to active subscriptions...")
activeMessages.filterValues { it is ActiveMessage.Subscription }.entries
.forEach { (oldId, oldActiveMessage) ->
oldActiveMessage as ActiveMessage.Subscription
activeMessages.remove(oldId)

val response = sendMessage(
Command.WithAnswer.Subscription(
request = oldActiveMessage.request,
eventFlow = oldActiveMessage.eventFlow,
onEvent = oldActiveMessage.onEvent,
),
)
if (response == null || response.success != true) {
Timber.e("Issue re-registering subscription with ${oldActiveMessage.request}")
}
var toRestore: Set<Long> = activeMessages.filterValues { it is ActiveMessage.Subscription }.keys
var retryDelay = DELAY_BEFORE_RECONNECT
while (toRestore.isNotEmpty()) {
if (connect()) {
// The server is reachable again, restart the backoff
retryDelay = DELAY_BEFORE_RECONNECT
toRestore = resubscribeActiveSubscriptions(toRestore)
if (toRestore.isEmpty()) return
Timber.w("${toRestore.size} subscriptions not restored, retrying in $retryDelay")
Comment thread
markfrancisonly marked this conversation as resolved.
Outdated
} else {
if (getConnectionState() == WebSocketState.ClosedAuth) {
Timber.e("Authentication failed, not retrying to resubscribe to active subscriptions")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When this happens the subscriptions stay in activeMessages but are never resubscribed. Even if auth is fixed later (a new token for instance), the next successful connection comes up with zero registrations on the server side and nothing restores them until the next socket closing.

The more I look at this, the more I think the resubscription trigger belongs at the end of connect() rather than in the closing path: when a fresh connection is established and activeMessages still contains Subscription entries from a previous connection, start the restore job for them. Semantically it reads as "connect, then re-register anything left over from the previous connection". A fresh socket has no registrations by definition, so any tracked subscription at that point needs restoring. This matters especially because any sendMessage can theoretically reconnect the WebSocket (the worker's ping does this every 30s) and leave the subscriptions tracked but not subscribed.

Note: it would need to launch the job (guarded by reconnectJob like today) rather than resubscribe inline: inline it would deadlock on connectedMutex and make unrelated sendMessage callers wait on the restoration. The closing-path loop would still be needed to actively retry while the server is unreachable; the connect() trigger would be the safety net for every other way a connection comes back.

This is an idea to explore.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The more I look at this, the more I think the resubscription trigger belongs at the end of connect() rather than in the closing path: when a fresh connection is established and activeMessages still contains Subscription entries from a previous connection, start the restore job for them. Semantically it reads as "connect, then re-register anything left over from the previous connection".

This makes logical sense. However, one of the reasons for putting it in the closing was to ensure a delay before retrying. Most connection failures are temporary due to a network change - immediately retrying will fail but a couple of seconds later there is a network again so it will work. The delay before attempting to setup a new connection should stay.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, I want to move only the logic of opening again the pending subscription out of this and directly in the connect path. I'm going to propose something within the PR

return
}
} else {
// TODO https://github.com/home-assistant/android/issues/5259 handle re-connection gracefully or terminates the flows
Timber.w("Unable to reconnect, cannot resubscribe to active subscriptions")
Timber.w(
"Unable to reconnect, retrying in $retryDelay to resubscribe to active subscriptions",
)
}
delay(retryDelay)
// Back off while the server stays unreachable so a long outage is not hammered
retryDelay = (retryDelay * 2).coerceAtMost(MAX_DELAY_BEFORE_RECONNECT)
// Subscriptions closed while waiting no longer need to be restored
toRestore = toRestore.filterTo(mutableSetOf()) { activeMessages[it] is ActiveMessage.Subscription }
}
}

/** @return the ids of the subscriptions that could not be restored and are kept for a retry */
private suspend fun resubscribeActiveSubscriptions(oldIds: Set<Long>): Set<Long> {
Timber.d("Resubscribing to active subscriptions...")
val failed = mutableSetOf<Long>()
oldIds.forEach { oldId ->
val oldActiveMessage = activeMessages[oldId] as? ActiveMessage.Subscription ?: return@forEach

val response = sendMessage(
Command.WithAnswer.Subscription(
request = oldActiveMessage.request,
eventFlow = oldActiveMessage.eventFlow,
onEvent = oldActiveMessage.onEvent,
),
)
if (response == null || response.success != true) {
failed += oldId
// Drop the rejected attempt, the kept original is retried instead
response?.id?.let { activeMessages.remove(it) }
Timber.e("Issue re-registering subscription with ${oldActiveMessage.request}")
} else {
activeMessages.remove(oldId)
}
}
Comment thread
markfrancisonly marked this conversation as resolved.
Outdated
return failed
}

private fun URL.toWebSocketURL(): String {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ import io.homeassistant.companion.android.common.data.websocket.impl.entities.En
import io.homeassistant.companion.android.common.data.websocket.impl.entities.GetConfigResponse
import io.homeassistant.companion.android.common.data.websocket.impl.entities.GetTodosResponse
import io.homeassistant.companion.android.common.data.websocket.impl.entities.MatterCommissionResponse
import io.homeassistant.companion.android.common.data.websocket.impl.entities.PongSocketResponse
import io.homeassistant.companion.android.common.data.websocket.impl.entities.RawMessageSocketResponse
import io.homeassistant.companion.android.common.data.websocket.impl.entities.StateChangedEvent
import io.homeassistant.companion.android.common.data.websocket.impl.entities.TemplateUpdatedEvent
Expand Down Expand Up @@ -69,14 +68,7 @@ class WebSocketRepositoryImpl internal constructor(
return webSocketCore.shutdown()
}

override suspend fun sendPing(): Boolean {
val socketResponse = webSocketCore.sendMessage(
mapOf(
"type" to "ping",
),
)
return socketResponse is PongSocketResponse
}
override suspend fun sendPing(): Boolean = webSocketCore.ping()

override suspend fun getConfig(): GetConfigResponse? {
val socketResponse = webSocketCore.sendMessage(
Expand Down
Loading