* [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>
*/
package org.openhab.binding.gardena.internal;
-import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.Iterator;
-import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.http.HttpMethod;
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.exception.GardenaDeviceNotFoundException;
import org.openhab.binding.gardena.internal.exception.GardenaException;
private GardenaSmartEventListener eventListener;
private HttpClient httpClient;
- private List<GardenaSmartWebSocket> webSockets = new ArrayList<>();
+ private Map<String, GardenaSmartWebSocket> webSockets = new HashMap<>();
private @Nullable PostOAuth2Response token;
private boolean initialized = false;
- private WebSocketFactory webSocketFactory;
+ private WebSocketClient webSocketClient;
private Set<Device> devicesToNotify = ConcurrentHashMap.newKeySet();
private @Nullable ScheduledFuture<?> deviceToNotifyFuture;
this.config = config;
this.eventListener = eventListener;
this.scheduler = scheduler;
- this.webSocketFactory = webSocketFactory;
logger.debug("Starting GardenaSmart");
try {
httpClient.setIdleTimeout(httpClient.getConnectTimeout());
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
verifyToken();
locationsResponse = loadLocations();
startWebsockets();
initialized = true;
+ } catch (GardenaException ex) {
+ dispose();
+ // pass GardenaException to calling function
+ throw ex;
} catch (Exception ex) {
dispose();
throw new GardenaException(ex.getMessage(), ex);
for (LocationDataItem location : locationsResponse.data) {
WebSocketCreatedResponse webSocketCreatedResponse = getWebsocketInfo(location.id);
String socketId = id + "-" + location.attributes.name;
- webSockets.add(new GardenaSmartWebSocket(this, webSocketCreatedResponse, config, scheduler,
- webSocketFactory, token, socketId));
+ webSockets.put(location.id, new GardenaSmartWebSocket(this, webSocketClient, scheduler,
+ webSocketCreatedResponse.data.attributes.url, token, socketId, location.id));
}
}
* Stops all websockets.
*/
private void stopWebsockets() {
- for (GardenaSmartWebSocket webSocket : webSockets) {
+ for (GardenaSmartWebSocket webSocket : webSockets.values()) {
webSocket.stop();
}
webSockets.clear();
if (status != 200 && status != 204 && status != 201 && status != 202) {
throw new GardenaException(String.format("Error %s %s, %s", status, contentResponse.getReason(),
- contentResponse.getContentAsString()));
+ contentResponse.getContentAsString()), status);
}
if (result == null) {
stopWebsockets();
try {
httpClient.stop();
+ webSocketClient.stop();
} catch (Exception e) {
// ignore
}
httpClient.destroy();
+ webSocketClient.destroy();
locationsResponse = new LocationsResponse();
allDevicesById.clear();
initialized = false;
*/
@Override
public synchronized void restartWebsockets() {
- logger.debug("Restarting GardenaSmart Webservice");
+ logger.debug("Restarting GardenaSmart Webservices");
stopWebsockets();
try {
startWebsockets();
} 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();
}
}
}
@Override
- public void onWebSocketClose() {
- restartWebsockets();
+ public void onWebSocketClose(String id) {
+ restartWebsocket(webSockets.get(id));
}
@Override
- public void onWebSocketError() {
- eventListener.onError();
+ 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();
+ }
}
@Override
import org.eclipse.jetty.websocket.client.WebSocketClient;
import org.eclipse.jetty.websocket.common.WebSocketSession;
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.WebSocketCreatedResponse;
-import org.openhab.core.io.net.http.WebSocketFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
private ByteBuffer pingPayload = ByteBuffer.wrap("ping".getBytes(StandardCharsets.UTF_8));
private @Nullable PostOAuth2Response token;
private String socketId;
+ private String locationID;
/**
* Starts the websocket session.
*/
- public GardenaSmartWebSocket(GardenaSmartWebSocketListener socketEventListener,
- WebSocketCreatedResponse webSocketCreatedResponse, GardenaConfig config, ScheduledExecutorService scheduler,
- WebSocketFactory webSocketFactory, @Nullable PostOAuth2Response token, String socketId) throws Exception {
+ public GardenaSmartWebSocket(GardenaSmartWebSocketListener socketEventListener, WebSocketClient webSocketClient,
+ ScheduledExecutorService scheduler, String url, @Nullable PostOAuth2Response token, String socketId,
+ String locationID) throws Exception {
this.socketEventListener = socketEventListener;
+ this.webSocketClient = webSocketClient;
this.scheduler = scheduler;
this.token = token;
this.socketId = socketId;
+ this.locationID = locationID;
- String webSocketId = String.valueOf(hashCode());
- webSocketClient = webSocketFactory.createWebSocketClient(webSocketId);
- webSocketClient.setConnectTimeout(config.getConnectionTimeout() * 1000L);
- webSocketClient.setStopTimeout(3000);
- webSocketClient.setMaxIdleTimeout(150000);
- webSocketClient.start();
-
+ session = (WebSocketSession) webSocketClient.connect(this, new URI(url)).get();
logger.debug("Connecting to Gardena Webservice ({})", socketId);
- session = (WebSocketSession) webSocketClient
- .connect(this, new URI(webSocketCreatedResponse.data.attributes.url)).get();
- session.setStopTimeout(3000);
}
/**
if (connectionTracker != null) {
connectionTracker.cancel(true);
}
- if (isRunning()) {
- logger.debug("Closing Gardena Webservice client ({})", socketId);
- try {
- session.close();
- } catch (Exception ex) {
- // ignore
- } finally {
- try {
- webSocketClient.stop();
- } catch (Exception e) {
- // ignore
- }
- }
+
+ logger.debug("Closing Gardena Webservice ({})", socketId);
+ try {
+ session.close();
+ } catch (Exception ex) {
+ // ignore
}
}
- /**
- * Returns true, if the websocket is running.
- */
- public synchronized boolean isRunning() {
- return session.isOpen();
+ public boolean isClosing() {
+ return this.closing;
+ }
+
+ 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
closing = false;
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
@OnWebSocketClose
public void onClose(int statusCode, String reason) {
+ logger.debug("Connection to Gardena Webservice was closed ({}): code: {}, reason: {}", socketId, statusCode,
+ reason);
+
if (!closing) {
- logger.debug("Connection to Gardena Webservice was closed ({}): code: {}, reason: {}", socketId, statusCode,
- reason);
- socketEventListener.onWebSocketClose();
+ // let listener handle restart of socket
+ socketEventListener.onWebSocketClose(locationID);
}
}
@OnWebSocketError
public void onError(Throwable cause) {
+ logger.debug("Gardena Webservice error ({})", socketId, cause); // log whole stack trace
+
if (!closing) {
- logger.warn("Gardena Webservice error ({}): {}, restarting", socketId, cause.getMessage());
- logger.debug("{}", cause.getMessage(), cause);
- socketEventListener.onWebSocketError();
+ // let listener handle restart of socket
+ socketEventListener.onWebSocketError(locationID);
}
}
* Sends a ping to tell the Gardena smart system that the client is alive.
*/
private void sendKeepAlivePing() {
- try {
- logger.trace("Sending ping ({})", socketId);
- 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 + ")");
+ 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 {
+ logger.trace("Sending ping ({})", socketId);
+ session.getRemote().sendPing(pingPayload);
+ } catch (IOException ex) {
+ logger.debug("Error while sending ping: {}", ex.getMessage());
+ }
}
- } catch (IOException ex) {
- logger.debug("{}", ex.getMessage());
}
}
}
/**
* 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.
*/
- void onWebSocketError();
+ void onWebSocketError(String id);
/**
* This method is called, whenever a new event comes from the Gardena service.
public class GardenaException extends IOException {
private static final long serialVersionUID = 8568935118878542270L;
+ private int status; // http status
public GardenaException(String message) {
super(message);
+ this.status = -1;
}
public GardenaException(Throwable ex) {
super(ex);
+ this.status = -1;
}
public GardenaException(@Nullable String message, Throwable 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;
}
}
@NonNullByDefault
public class GardenaAccountHandler extends BaseBridgeHandler implements GardenaSmartEventListener {
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;
} catch (GardenaException ex) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, ex.getMessage());
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());
}
});
/**
* Schedules a reinitialization, if Gardena smart system account is not reachable.
*/
- private void scheduleReinitialize() {
+ private void scheduleReinitialize(long delay, TimeUnit unit) {
scheduler.schedule(() -> {
if (getThing().getStatus() != ThingStatus.UNINITIALIZED) {
initializeGardena();
}
- }, REINITIALIZE_DELAY_SECONDS, TimeUnit.SECONDS);
+ }, delay, unit);
}
@Override
public void onError() {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Connection lost");
disposeGardena();
- scheduleReinitialize();
+ scheduleReinitialize(REINITIALIZE_DELAY_SECONDS, TimeUnit.SECONDS);
}
}