/* * RemoteServerEventListener.java * * Copyright (C) 2009-12 by RStudio, Inc. * * Unless you have received this program directly from RStudio pursuant * to the terms of a commercial license agreement with RStudio, then * this program is licensed to you under the terms of version 3 of the * GNU Affero General Public License. This program is distributed WITHOUT * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT, * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details. * */ package org.rstudio.studio.client.server.remote; import com.google.gwt.core.client.GWT; import com.google.gwt.core.client.JsArray; import com.google.gwt.user.client.Timer; import com.google.gwt.user.client.Window; import com.google.gwt.user.client.Window.ClosingEvent; import com.google.gwt.user.client.Window.ClosingHandler; import org.rstudio.core.client.jsonrpc.RpcError; import org.rstudio.core.client.jsonrpc.RpcRequest; import org.rstudio.core.client.jsonrpc.RpcRequestCallback; import org.rstudio.core.client.jsonrpc.RpcResponse; import org.rstudio.studio.client.application.events.*; import org.rstudio.studio.client.server.ServerError; import org.rstudio.studio.client.server.ServerRequestCallback; import java.util.HashMap; class RemoteServerEventListener { /** * Stores the context needed to complete an async request. */ static class AsyncRequestInfo { AsyncRequestInfo(RpcRequest request, RpcRequestCallback callback) { this.request = request; this.callback = callback; } public final RpcRequest request; public final RpcRequestCallback callback; } public RemoteServerEventListener(RemoteServer server, ClientEventHandler externalEventHandler) { server_ = server; externalEventHandler_ = externalEventHandler; eventDispatcher_ = new ClientEventDispatcher(server_.getEventBus()); lastEventId_ = -1; listenCount_ = 0; listenErrorCount_ = 0; isListening_ = false; sessionWasQuit_ = false; // we take the liberty of stopping ourselves if the window is on // the verge of being closed. this allows us to prevent the scenario: // // - window closes and the browser terminates the listener connection // - onError is called when the connection is terminated -- this results // in another call to listen() which starts a new connection // - now we have a "leftover" connection still active with the server // even after the user has left the page // // we can't use Window CloseEvent because this occurs *after* the // connection is terminated and restarted in onError. we currently // don't handle the ClosingEvent elsewhere in the application so calling // stop() here is as good as calling it in CloseEvent. however, even // if we did handle ClosingEvent and show a prompt which resulted in // the window NOT closing this would still be OK as the event listener // would still be restarted as necessary by the call to ensureEvents // // note that in the future if we need to make sure event listening // is preserved even in the close cancelled case described above // (e.g. for multi-user cases) then we would need to make sure there // is another way to restart the listener (perhaps a global timer // that checks for isListening every few seconds, or perhaps some // abstraction over addWindowClosingHandler that allows "undo" of // things which were closed or shutdown during closing Window.addWindowClosingHandler(new ClosingHandler() { public void onWindowClosing(ClosingEvent event) { stop(); } }); } public void start() { // start should never be called on a running event listener! // (need to protect against extra requests going to the server // and starving the browser of its 2 connections) if (isListening_) stop(); // maintain flag indicating that we *should* be listening (allows us to // know when to restart in the case that we are unexpectedly cutoff) isListening_ = true; // reset listen count. this will allow us to delay listening on the // second listen (to prevent the "perpetual loading" problem) listenCount_ = 0; // reset our lastEventId to make sure we get all events which are // currently pending on the server. note in the case of "restarting" // the event listener setting this to -1 could in theory cause us to // receive an event twice (because the reset to -1 causes us to never // confirm receipt of the event with the server). in practice this // would a) be very unlikely; b) not be that big of a deal; and c) is // judged preferrable than doing something more complex in this code // which might avoid dupes but cause other bugs (such as missing events // from the server). note also that when we go multi-user we'll be // revisiting this mechanism again so there will be an opportunity to // eliminate this scenario then lastEventId_ = -1; // start listening listen(); } public void stop() { isListening_ = false; listenCount_ = 0; if (activeRequestCallback_ != null) { activeRequestCallback_.cancel(); activeRequestCallback_ = null; } if (activeRequest_ != null) { activeRequest_.cancel(); activeRequest_ = null; } } // ensure that we are actively listening for events (used to make // sure that we restart listening when the session is about to resume // after a suspension) public void ensureListening(final int attempts) { // exit if we are now listening if (isListening_) return; // exit if we have already quit or been disconnected if (sessionWasQuit_ || server_.isDisconnected()) return; // attempt to start the service start(); // if appropriate, schedule another attempt in 250ms final int attemptsRemaining = attempts - 1; if (attemptsRemaining > 0) { new Timer() { public void run() { ensureListening(attemptsRemaining); } }.schedule(250); } } // ensure that events are received during the next short time interval. // this not only starts listening if we aren't currently listening but // also ensures (via a Watchdog) that events are received (and if they // are not received restarts the event listener) public void ensureEvents() { // if we aren't listening then start us up if (!isListening_) { start(); } // if we are listening then use the Watchdog to still make sure we // receive the events even if it requires restarting else { // NOTE: Watchdog is required to work around pathological cases // where the browser has terminated our request for events but // we have not been notified nor can we programmatically detect it. // we need a way to recover and this is it. we have observed this // behavior in webkit if: // // 1) we do not use DeferredCommand/doListen (see below); and // // 2) the user navigates Back within a Frame // // can only imagine that it could happen in other scenarios! if (!watchdog_.isRunning()) watchdog_.run(kWatchdogIntervalMs); } } private void restart() { stop(); start(); } private void listen() { // bounce listen to ensure it is never added to the browser's internal // list of requests bound to the current page load. being on this list // (at least in webkit, perhaps in others) results in at least 2 and // perhaps other problems: // // 1) perpetual "Loading..." indicator displayed to user (user can // also then "cancel" the event request!); and // // 2) terimation of the request without warning by the browser when // the user hits the Back button within a frame hosted on the page // (note in this case we get no error so think the request is still // running -- see Watchdog for workaround to this general class of // issues) // determine bounce ms (do a bigger bounce for the second listen // request as this is the one which gets us stuck in "perpetual loading") int bounceMs = 1; if (++listenCount_ == 2) bounceMs = kSecondListenBounceMs; Timer listenTimer = new Timer() { @Override public void run() { doListen(); } }; listenTimer.schedule(bounceMs); } private void doListen() { // abort if we are no longer running if (!isListening_) return; // setup request callback (save reference for cancellation) activeRequestCallback_ = new ServerRequestCallback<JsArray<ClientEvent>>() { @Override public void onResponseReceived(JsArray<ClientEvent> events) { // keep watchdog appraised of successful receipt of events watchdog_.notifyResponseReceived(); try { // only processs events if we are still listening if (isListening_ && (events != null)) { for (int i=0; i<events.length(); i++) { // we can stop listening in the middle of dispatching // events (e.g. if we dispatch a Suicide event) so we // need to check the listening_ flag before each event // is dispatched if (!isListening_) return; // disppatch event ClientEvent event = events.get(i); dispatchEvent(event); lastEventId_ = event.getId(); } } } // catch all here to make sure that in all cases we call // listen() again after processing catch(Throwable e) { GWT.log("ERROR: Processing client events", e); } // listen for more events listen(); } @Override public void onError(ServerError error) { // stop listening for events stop(); // if this was server unavailable then signal event and return if (error.getCode() == ServerError.UNAVAILABLE) { ServerUnavailableEvent event = new ServerUnavailableEvent(); server_.getEventBus().fireEvent(event); return; } // attempt to restart listening, but throttle restart attempts // in both timing (500ms delay) and quantity (no more than 5 // attempts). We do this because unthrottled restart attempts could // result in our server getting hammered with requests) if (listenErrorCount_++ <= 5) { Timer startTimer = new Timer() { @Override public void run() { // only start again if we haven't been started // by some other means (e.g. ensureListening, etc) if (!isListening_) start(); } }; startTimer.schedule(500); } // otherwise reset the listen error count and remain stopped else { listenErrorCount_ = 0; } } }; // retry handler (restart listener) RetryHandler retryHandler = new RetryHandler() { public void onRetry() { // need to do a full restart to ensure that the existing // activeRequest_ and activeRequestCallback_ are cleaned up // and all state is reset correctly restart(); } public void onError(RpcError error) { // error while attempting to recover, to be on the safe side // we simply stop listening for events. if rather than stopping // we restarted we would open ourselves up to a situation // where we keep hitting the same error over and over again. stop(); } }; // send request activeRequest_ = server_.getEvents(lastEventId_, activeRequestCallback_, retryHandler); } private void dispatchEvent(ClientEvent event) { // do some special handling before calling the standard dispatcher String type = event.getType(); // we handle async completions directly if (type.equals(ClientEvent.AsyncCompletion)) { AsyncCompletion completion = event.getData(); String handle = completion.getHandle(); AsyncRequestInfo req = asyncRequests_.remove(handle); if (req != null) { req.callback.onResponseReceived(req.request, completion.getResponse()); } else { // We haven't seen this request yet. Store it for later, // maybe it's just taking a long time for the request // to complete. asyncResponses_.put(handle, completion.getResponse()); } } else { // if there is a quit event then we set an internal flag to avoid // ensureListening/ensureEvents calls trying to spark the event // stream back up after the user has quit if (type.equals(ClientEvent.Quit)) sessionWasQuit_ = true; // perform standard handling eventDispatcher_.enqueEvent(event); // allow any external handler registered to see the event if (externalEventHandler_ != null) externalEventHandler_.onClientEvent(event); } } // NOTE: the design of the Watchdog likely results in more restarts of // the event service than is optimal. when an rpc call reports that // events are pending and the Watchdog is invoked it is very likely // that the events have already been delievered in response to the // previous poll. In this case the Watchdog "misses" those events which // were already delivered and subsequently assumes that the service // needs to be restarted private class Watchdog { public void run(int waitMs) { isRunning_ = true; responseReceived_ = false ; Timer timer = new Timer() { public void run() { try { if (!responseReceived_) { // ensure that the workbench wasn't closed while we // were waiting for the timer to run if (!sessionWasQuit_) restart(); } } catch(Throwable e) { GWT.log("Error restarting event source", e); } isRunning_ = false; responseReceived_ = false ; } }; timer.schedule(waitMs); } public boolean isRunning() { return isRunning_ ; } public void notifyResponseReceived() { responseReceived_ = true; } private boolean isRunning_ = false; private boolean responseReceived_ = false; } public void registerAsyncHandle(String asyncHandle, RpcRequest request, RpcRequestCallback callback) { RpcResponse response = asyncResponses_.remove(asyncHandle); if (response == null) { // We don't have the response for this request--this is // the normal case. asyncRequests_.put(asyncHandle, new AsyncRequestInfo(request, callback)); } else { // We already have the response--the request must've taken // a long time to return. callback.onResponseReceived(request, response); } } private final RemoteServer server_; // note: kSecondListenDelayMs must be less than kWatchdogIntervalMs // (by a reasonable margin) to void the watchdog getting involved // unnecessarily during a listen delay private final int kWatchdogIntervalMs = 1000; private final int kSecondListenBounceMs = 250; private boolean isListening_; private int lastEventId_ ; private int listenCount_ ; private int listenErrorCount_ ; private boolean sessionWasQuit_ ; private RpcRequest activeRequest_ ; private ServerRequestCallback<JsArray<ClientEvent>> activeRequestCallback_; private final ClientEventDispatcher eventDispatcher_; private final ClientEventHandler externalEventHandler_; private Watchdog watchdog_ = new Watchdog(); // Stores async requests that expect to be completed later. private final HashMap<String, AsyncRequestInfo> asyncRequests_ = new HashMap<String, AsyncRequestInfo>(); // Stores any async responses that didn't have matching requests at the // time they were received. This is to deal with any race conditions where // the completion occurs before we even finished making the request. private final HashMap<String, RpcResponse> asyncResponses_ = new HashMap<String, RpcResponse>(); }