[gardena] Fix handling of websocket connection losses that causes memory leaks (#11825)

* [gardena] Fix handling of websocket connection losses that causes memory leaks

* The binding no longer restarts websockets more than once if the connection is lost

Signed-off-by: Nico Brüttner <n@bruettner.de>
This commit is contained in:
Bruetti1991 2022-06-14 21:12:14 +02:00 committed by GitHub
parent 58342f72ed
commit fd9fa722d3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 149 additions and 72 deletions

View File

@ -12,11 +12,9 @@
*/ */
package org.openhab.binding.gardena.internal; package org.openhab.binding.gardena.internal;
import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.HashMap; import java.util.HashMap;
import java.util.Iterator; import java.util.Iterator;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
@ -37,6 +35,7 @@ import org.eclipse.jetty.client.util.StringContentProvider;
import org.eclipse.jetty.http.HttpHeader; import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.http.HttpMethod; import org.eclipse.jetty.http.HttpMethod;
import org.eclipse.jetty.util.Fields; import org.eclipse.jetty.util.Fields;
import org.eclipse.jetty.websocket.client.WebSocketClient;
import org.openhab.binding.gardena.internal.config.GardenaConfig; import org.openhab.binding.gardena.internal.config.GardenaConfig;
import org.openhab.binding.gardena.internal.exception.GardenaDeviceNotFoundException; import org.openhab.binding.gardena.internal.exception.GardenaDeviceNotFoundException;
import org.openhab.binding.gardena.internal.exception.GardenaException; import org.openhab.binding.gardena.internal.exception.GardenaException;
@ -87,10 +86,10 @@ public class GardenaSmartImpl implements GardenaSmart, GardenaSmartWebSocketList
private GardenaSmartEventListener eventListener; private GardenaSmartEventListener eventListener;
private HttpClient httpClient; private HttpClient httpClient;
private List<GardenaSmartWebSocket> webSockets = new ArrayList<>(); private Map<String, GardenaSmartWebSocket> webSockets = new HashMap<>();
private @Nullable PostOAuth2Response token; private @Nullable PostOAuth2Response token;
private boolean initialized = false; private boolean initialized = false;
private WebSocketFactory webSocketFactory; private WebSocketClient webSocketClient;
private Set<Device> devicesToNotify = ConcurrentHashMap.newKeySet(); private Set<Device> devicesToNotify = ConcurrentHashMap.newKeySet();
private @Nullable ScheduledFuture<?> deviceToNotifyFuture; private @Nullable ScheduledFuture<?> deviceToNotifyFuture;
@ -103,7 +102,6 @@ public class GardenaSmartImpl implements GardenaSmart, GardenaSmartWebSocketList
this.config = config; this.config = config;
this.eventListener = eventListener; this.eventListener = eventListener;
this.scheduler = scheduler; this.scheduler = scheduler;
this.webSocketFactory = webSocketFactory;
logger.debug("Starting GardenaSmart"); logger.debug("Starting GardenaSmart");
try { try {
@ -112,6 +110,13 @@ public class GardenaSmartImpl implements GardenaSmart, GardenaSmartWebSocketList
httpClient.setIdleTimeout(httpClient.getConnectTimeout()); httpClient.setIdleTimeout(httpClient.getConnectTimeout());
httpClient.start(); httpClient.start();
String webSocketId = String.valueOf(hashCode());
webSocketClient = webSocketFactory.createWebSocketClient(webSocketId);
webSocketClient.setConnectTimeout(config.getConnectionTimeout() * 1000L);
webSocketClient.setStopTimeout(3000);
webSocketClient.setMaxIdleTimeout(150000);
webSocketClient.start();
// initially load access token // initially load access token
verifyToken(); verifyToken();
locationsResponse = loadLocations(); locationsResponse = loadLocations();
@ -132,6 +137,10 @@ public class GardenaSmartImpl implements GardenaSmart, GardenaSmartWebSocketList
startWebsockets(); startWebsockets();
initialized = true; initialized = true;
} catch (GardenaException ex) {
dispose();
// pass GardenaException to calling function
throw ex;
} catch (Exception ex) { } catch (Exception ex) {
dispose(); dispose();
throw new GardenaException(ex.getMessage(), ex); throw new GardenaException(ex.getMessage(), ex);
@ -145,8 +154,8 @@ public class GardenaSmartImpl implements GardenaSmart, GardenaSmartWebSocketList
for (LocationDataItem location : locationsResponse.data) { for (LocationDataItem location : locationsResponse.data) {
WebSocketCreatedResponse webSocketCreatedResponse = getWebsocketInfo(location.id); WebSocketCreatedResponse webSocketCreatedResponse = getWebsocketInfo(location.id);
String socketId = id + "-" + location.attributes.name; String socketId = id + "-" + location.attributes.name;
webSockets.add(new GardenaSmartWebSocket(this, webSocketCreatedResponse, config, scheduler, webSockets.put(location.id, new GardenaSmartWebSocket(this, webSocketClient, scheduler,
webSocketFactory, token, socketId)); webSocketCreatedResponse.data.attributes.url, token, socketId, location.id));
} }
} }
@ -154,7 +163,7 @@ public class GardenaSmartImpl implements GardenaSmart, GardenaSmartWebSocketList
* Stops all websockets. * Stops all websockets.
*/ */
private void stopWebsockets() { private void stopWebsockets() {
for (GardenaSmartWebSocket webSocket : webSockets) { for (GardenaSmartWebSocket webSocket : webSockets.values()) {
webSocket.stop(); webSocket.stop();
} }
webSockets.clear(); webSockets.clear();
@ -203,7 +212,7 @@ public class GardenaSmartImpl implements GardenaSmart, GardenaSmartWebSocketList
if (status != 200 && status != 204 && status != 201 && status != 202) { if (status != 200 && status != 204 && status != 201 && status != 202) {
throw new GardenaException(String.format("Error %s %s, %s", status, contentResponse.getReason(), throw new GardenaException(String.format("Error %s %s, %s", status, contentResponse.getReason(),
contentResponse.getContentAsString())); contentResponse.getContentAsString()), status);
} }
if (result == null) { if (result == null) {
@ -297,10 +306,12 @@ public class GardenaSmartImpl implements GardenaSmart, GardenaSmartWebSocketList
stopWebsockets(); stopWebsockets();
try { try {
httpClient.stop(); httpClient.stop();
webSocketClient.stop();
} catch (Exception e) { } catch (Exception e) {
// ignore // ignore
} }
httpClient.destroy(); httpClient.destroy();
webSocketClient.destroy();
locationsResponse = new LocationsResponse(); locationsResponse = new LocationsResponse();
allDevicesById.clear(); allDevicesById.clear();
initialized = false; initialized = false;
@ -311,12 +322,17 @@ public class GardenaSmartImpl implements GardenaSmart, GardenaSmartWebSocketList
*/ */
@Override @Override
public synchronized void restartWebsockets() { public synchronized void restartWebsockets() {
logger.debug("Restarting GardenaSmart Webservice"); logger.debug("Restarting GardenaSmart Webservices");
stopWebsockets(); stopWebsockets();
try { try {
startWebsockets(); startWebsockets();
} catch (Exception ex) { } catch (Exception ex) {
logger.warn("Restarting GardenaSmart Webservice failed: {}, restarting binding", ex.getMessage()); // restart binding
if (logger.isDebugEnabled()) {
logger.warn("Restarting GardenaSmart Webservices failed! Restarting binding", ex);
} else {
logger.warn("Restarting GardenaSmart Webservices failed: {}! Restarting binding", ex.getMessage());
}
eventListener.onError(); eventListener.onError();
} }
} }
@ -350,14 +366,39 @@ public class GardenaSmartImpl implements GardenaSmart, GardenaSmartWebSocketList
} }
@Override @Override
public void onWebSocketClose() { public void onWebSocketClose(String id) {
restartWebsockets(); restartWebsocket(webSockets.get(id));
} }
@Override @Override
public void onWebSocketError() { public void onWebSocketError(String id) {
restartWebsocket(webSockets.get(id));
}
private void restartWebsocket(@Nullable GardenaSmartWebSocket socket) {
synchronized (this) {
if (socket != null && !socket.isClosing()) {
// close socket, if still open
logger.info("Restarting GardenaSmart Webservice ({})", socket.getSocketID());
socket.stop();
} else {
// if socket is already closing, exit function and do not restart socket
return;
}
}
try {
Thread.sleep(3000);
WebSocketCreatedResponse webSocketCreatedResponse = getWebsocketInfo(socket.getLocationID());
// only restart single socket, do not restart binding
socket.restart(webSocketCreatedResponse.data.attributes.url);
} catch (Exception ex) {
// restart binding on error
logger.warn("Restarting GardenaSmart Webservice failed ({}): {}, restarting binding", socket.getSocketID(),
ex.getMessage());
eventListener.onError(); eventListener.onError();
} }
}
@Override @Override
public void onWebSocketMessage(String msg) { public void onWebSocketMessage(String msg) {

View File

@ -34,10 +34,7 @@ import org.eclipse.jetty.websocket.api.extensions.Frame;
import org.eclipse.jetty.websocket.client.WebSocketClient; import org.eclipse.jetty.websocket.client.WebSocketClient;
import org.eclipse.jetty.websocket.common.WebSocketSession; import org.eclipse.jetty.websocket.common.WebSocketSession;
import org.eclipse.jetty.websocket.common.frames.PongFrame; import org.eclipse.jetty.websocket.common.frames.PongFrame;
import org.openhab.binding.gardena.internal.config.GardenaConfig;
import org.openhab.binding.gardena.internal.model.dto.api.PostOAuth2Response; import org.openhab.binding.gardena.internal.model.dto.api.PostOAuth2Response;
import org.openhab.binding.gardena.internal.model.dto.api.WebSocketCreatedResponse;
import org.openhab.core.io.net.http.WebSocketFactory;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -63,29 +60,23 @@ public class GardenaSmartWebSocket {
private ByteBuffer pingPayload = ByteBuffer.wrap("ping".getBytes(StandardCharsets.UTF_8)); private ByteBuffer pingPayload = ByteBuffer.wrap("ping".getBytes(StandardCharsets.UTF_8));
private @Nullable PostOAuth2Response token; private @Nullable PostOAuth2Response token;
private String socketId; private String socketId;
private String locationID;
/** /**
* Starts the websocket session. * Starts the websocket session.
*/ */
public GardenaSmartWebSocket(GardenaSmartWebSocketListener socketEventListener, public GardenaSmartWebSocket(GardenaSmartWebSocketListener socketEventListener, WebSocketClient webSocketClient,
WebSocketCreatedResponse webSocketCreatedResponse, GardenaConfig config, ScheduledExecutorService scheduler, ScheduledExecutorService scheduler, String url, @Nullable PostOAuth2Response token, String socketId,
WebSocketFactory webSocketFactory, @Nullable PostOAuth2Response token, String socketId) throws Exception { String locationID) throws Exception {
this.socketEventListener = socketEventListener; this.socketEventListener = socketEventListener;
this.webSocketClient = webSocketClient;
this.scheduler = scheduler; this.scheduler = scheduler;
this.token = token; this.token = token;
this.socketId = socketId; this.socketId = socketId;
this.locationID = locationID;
String webSocketId = String.valueOf(hashCode()); session = (WebSocketSession) webSocketClient.connect(this, new URI(url)).get();
webSocketClient = webSocketFactory.createWebSocketClient(webSocketId);
webSocketClient.setConnectTimeout(config.getConnectionTimeout() * 1000L);
webSocketClient.setStopTimeout(3000);
webSocketClient.setMaxIdleTimeout(150000);
webSocketClient.start();
logger.debug("Connecting to Gardena Webservice ({})", socketId); logger.debug("Connecting to Gardena Webservice ({})", socketId);
session = (WebSocketSession) webSocketClient
.connect(this, new URI(webSocketCreatedResponse.data.attributes.url)).get();
session.setStopTimeout(3000);
} }
/** /**
@ -97,27 +88,30 @@ public class GardenaSmartWebSocket {
if (connectionTracker != null) { if (connectionTracker != null) {
connectionTracker.cancel(true); connectionTracker.cancel(true);
} }
if (isRunning()) {
logger.debug("Closing Gardena Webservice client ({})", socketId); logger.debug("Closing Gardena Webservice ({})", socketId);
try { try {
session.close(); session.close();
} catch (Exception ex) { } catch (Exception ex) {
// ignore // ignore
} finally {
try {
webSocketClient.stop();
} catch (Exception e) {
// ignore
}
}
} }
} }
/** public boolean isClosing() {
* Returns true, if the websocket is running. return this.closing;
*/ }
public synchronized boolean isRunning() {
return session.isOpen(); public String getSocketID() {
return this.socketId;
}
public String getLocationID() {
return this.locationID;
}
public void restart(String newUrl) throws Exception {
logger.debug("Reconnecting to Gardena Webservice ({})", socketId);
session = (WebSocketSession) webSocketClient.connect(this, new URI(newUrl)).get();
} }
@OnWebSocketConnect @OnWebSocketConnect
@ -125,7 +119,13 @@ public class GardenaSmartWebSocket {
closing = false; closing = false;
logger.debug("Connected to Gardena Webservice ({})", socketId); logger.debug("Connected to Gardena Webservice ({})", socketId);
connectionTracker = scheduler.scheduleWithFixedDelay(this::sendKeepAlivePing, 2, 2, TimeUnit.MINUTES); ScheduledFuture<?> connectionTracker = this.connectionTracker;
if (connectionTracker != null && !connectionTracker.isCancelled()) {
connectionTracker.cancel(false);
}
// start sending PING every two minutes
this.connectionTracker = scheduler.scheduleWithFixedDelay(this::sendKeepAlivePing, 2, 2, TimeUnit.MINUTES);
} }
@OnWebSocketFrame @OnWebSocketFrame
@ -138,19 +138,22 @@ public class GardenaSmartWebSocket {
@OnWebSocketClose @OnWebSocketClose
public void onClose(int statusCode, String reason) { public void onClose(int statusCode, String reason) {
if (!closing) {
logger.debug("Connection to Gardena Webservice was closed ({}): code: {}, reason: {}", socketId, statusCode, logger.debug("Connection to Gardena Webservice was closed ({}): code: {}, reason: {}", socketId, statusCode,
reason); reason);
socketEventListener.onWebSocketClose();
if (!closing) {
// let listener handle restart of socket
socketEventListener.onWebSocketClose(locationID);
} }
} }
@OnWebSocketError @OnWebSocketError
public void onError(Throwable cause) { public void onError(Throwable cause) {
logger.debug("Gardena Webservice error ({})", socketId, cause); // log whole stack trace
if (!closing) { if (!closing) {
logger.warn("Gardena Webservice error ({}): {}, restarting", socketId, cause.getMessage()); // let listener handle restart of socket
logger.debug("{}", cause.getMessage(), cause); socketEventListener.onWebSocketError(locationID);
socketEventListener.onWebSocketError();
} }
} }
@ -166,16 +169,19 @@ public class GardenaSmartWebSocket {
* Sends a ping to tell the Gardena smart system that the client is alive. * Sends a ping to tell the Gardena smart system that the client is alive.
*/ */
private void sendKeepAlivePing() { private void sendKeepAlivePing() {
final PostOAuth2Response accessToken = token;
if ((Instant.now().getEpochSecond() - lastPong.getEpochSecond() > WEBSOCKET_IDLE_TIMEOUT) || accessToken == null
|| accessToken.isAccessTokenExpired()) {
session.close(1000, "Timeout manually closing dead connection (" + socketId + ")");
} else {
if (session.isOpen()) {
try { try {
logger.trace("Sending ping ({})", socketId); logger.trace("Sending ping ({})", socketId);
session.getRemote().sendPing(pingPayload); session.getRemote().sendPing(pingPayload);
final PostOAuth2Response accessToken = token;
if ((Instant.now().getEpochSecond() - lastPong.getEpochSecond() > WEBSOCKET_IDLE_TIMEOUT)
|| accessToken == null || accessToken.isAccessTokenExpired()) {
session.close(1000, "Timeout manually closing dead connection (" + socketId + ")");
}
} catch (IOException ex) { } catch (IOException ex) {
logger.debug("{}", ex.getMessage()); logger.debug("Error while sending ping: {}", ex.getMessage());
}
}
} }
} }
} }

View File

@ -26,12 +26,12 @@ public interface GardenaSmartWebSocketListener {
/** /**
* This method is called, when the evenRunner stops abnormally (statuscode <> 1000). * This method is called, when the evenRunner stops abnormally (statuscode <> 1000).
*/ */
void onWebSocketClose(); void onWebSocketClose(String id);
/** /**
* This method is called when the Gardena websocket services throws an onError. * This method is called when the Gardena websocket services throws an onError.
*/ */
void onWebSocketError(); void onWebSocketError(String id);
/** /**
* This method is called, whenever a new event comes from the Gardena service. * This method is called, whenever a new event comes from the Gardena service.

View File

@ -26,16 +26,39 @@ import org.eclipse.jdt.annotation.Nullable;
public class GardenaException extends IOException { public class GardenaException extends IOException {
private static final long serialVersionUID = 8568935118878542270L; private static final long serialVersionUID = 8568935118878542270L;
private int status; // http status
public GardenaException(String message) { public GardenaException(String message) {
super(message); super(message);
this.status = -1;
} }
public GardenaException(Throwable ex) { public GardenaException(Throwable ex) {
super(ex); super(ex);
this.status = -1;
} }
public GardenaException(@Nullable String message, Throwable cause) { public GardenaException(@Nullable String message, Throwable cause) {
super(message, cause); super(message, cause);
this.status = -1;
}
public GardenaException(String message, int status) {
super(message);
this.status = status;
}
public GardenaException(Throwable ex, int status) {
super(ex);
this.status = status;
}
public GardenaException(@Nullable String message, Throwable cause, int status) {
super(message, cause);
this.status = status;
}
public int getStatus() {
return this.status;
} }
} }

View File

@ -50,7 +50,8 @@ import org.slf4j.LoggerFactory;
@NonNullByDefault @NonNullByDefault
public class GardenaAccountHandler extends BaseBridgeHandler implements GardenaSmartEventListener { public class GardenaAccountHandler extends BaseBridgeHandler implements GardenaSmartEventListener {
private final Logger logger = LoggerFactory.getLogger(GardenaAccountHandler.class); private final Logger logger = LoggerFactory.getLogger(GardenaAccountHandler.class);
private final long REINITIALIZE_DELAY_SECONDS = 10; private static final long REINITIALIZE_DELAY_SECONDS = 120;
private static final long REINITIALIZE_DELAY_HOURS_LIMIT_EXCEEDED = 24;
private @Nullable GardenaDeviceDiscoveryService discoveryService; private @Nullable GardenaDeviceDiscoveryService discoveryService;
@ -97,7 +98,13 @@ public class GardenaAccountHandler extends BaseBridgeHandler implements GardenaS
} catch (GardenaException ex) { } catch (GardenaException ex) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, ex.getMessage()); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, ex.getMessage());
disposeGardena(); disposeGardena();
scheduleReinitialize(); if (ex.getStatus() == 429) {
// if there was an error 429 (Too Many Requests), wait for 24 hours before trying again
scheduleReinitialize(REINITIALIZE_DELAY_HOURS_LIMIT_EXCEEDED, TimeUnit.HOURS);
} else {
// otherwise reinitialize after 120 seconds
scheduleReinitialize(REINITIALIZE_DELAY_SECONDS, TimeUnit.SECONDS);
}
logger.warn("{}", ex.getMessage()); logger.warn("{}", ex.getMessage());
} }
}); });
@ -106,12 +113,12 @@ public class GardenaAccountHandler extends BaseBridgeHandler implements GardenaS
/** /**
* Schedules a reinitialization, if Gardena smart system account is not reachable. * Schedules a reinitialization, if Gardena smart system account is not reachable.
*/ */
private void scheduleReinitialize() { private void scheduleReinitialize(long delay, TimeUnit unit) {
scheduler.schedule(() -> { scheduler.schedule(() -> {
if (getThing().getStatus() != ThingStatus.UNINITIALIZED) { if (getThing().getStatus() != ThingStatus.UNINITIALIZED) {
initializeGardena(); initializeGardena();
} }
}, REINITIALIZE_DELAY_SECONDS, TimeUnit.SECONDS); }, delay, unit);
} }
@Override @Override
@ -190,6 +197,6 @@ public class GardenaAccountHandler extends BaseBridgeHandler implements GardenaS
public void onError() { public void onError() {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Connection lost"); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Connection lost");
disposeGardena(); disposeGardena();
scheduleReinitialize(); scheduleReinitialize(REINITIALIZE_DELAY_SECONDS, TimeUnit.SECONDS);
} }
} }