// Copyright 2012 Google Inc. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package com.google.collide.client.xhrmonitor; import com.google.collide.client.util.logging.Log; import com.google.collide.json.shared.JsonStringMap; import com.google.collide.json.shared.JsonStringMap.IterationCallback; import com.google.collide.shared.util.JsonCollections; import com.google.gwt.core.client.JavaScriptObject; import elemental.dom.XMLHttpRequest; /** * The warden watches XMLHttpRequests so we can monitor how many are going out * and in and log it when it passes some threshold. The request kill feature * should not be used in production as it could degrade the user experience. */ public class XhrWarden { /** * If more than {@code WARDEN_WARNING_THRESHOLD} xhr requests are opened the * warden will trigger a warning to the warden listener. */ public static final int WARDEN_WARNING_THRESHOLD = 7; /** * If {@code WARDEN_REQUEST_LIMIT} xhr's are already opened the oldest one * gets logged and is killed automatically. */ /* * After switching to SPDY, we don't have a hard-limit on simultaneous XHRs so * there's no need to kill. Leaving this in just in-case anyone needs to use it * for debug purposes. */ public static final int WARDEN_REQUEST_LIMIT = Integer.MAX_VALUE; /** * The underlying singleton used by the warden implementation. */ private static WardenImpl wardenManager; /** * Initializes the warden. */ public static WardenRequestManager watch() { if (wardenManager == null) { wardenManager = new WardenImpl(WARDEN_WARNING_THRESHOLD, WARDEN_REQUEST_LIMIT); createWarden(wardenManager); } return wardenManager; } /** * Listener for warden events */ public interface WardenListener { /** * Called when the warning threshold of XHR requests is reached. */ public void onWarning(WardenRequestManager manager); /** * Called when the hard request limit is reached and the oldest XHR request has been killed. * * @param request The request that was killed. */ public void onEmergency(WardenRequestManager manager, WardenXhrRequest request); } /** * Defines public facing methods of a warden request manager. */ public interface WardenRequestManager { public int getRequestCount(); /** * Dumps all requests to the console. */ void dumpRequestsToConsole(); /** * Iterates over all open requests objects. */ public void iterate(IterationCallback<WardenXhrRequest> callback); /** * Adds a custom header to the list of headers to be added by the XhrWarden. * This is meant for debugging purposes since this will get added to every * XHR request made by the client. */ public void addCustomHeader(String header, String value); } interface WardenReadyStateHandler { public void onRequestOpen(WardenXhrRequest request); public void onRequestDone(WardenXhrRequest request); public void onRequestOpening(WardenXhrRequest request); public void doListRequests(); } /** * Receives javascript events from the warden and decides which XHR requests * may need to die. Also deals with logging to counselor if things start going * awry. */ static class WardenImpl implements WardenReadyStateHandler, WardenRequestManager { private final JsonStringMap<WardenXhrRequest> openXhrRequests; private final JsonStringMap<String> customHeaders; private boolean alreadyLoggedError; private final WardenListener eventListener; private final int requestWarningLimit; private final int requestErrorLimit; /** * The default event handler for warden events. */ private static class WardenEventHandler implements WardenListener { @Override public void onEmergency(WardenRequestManager manager, WardenXhrRequest request) { String message = "The Warden killed an xhr request due to capacity issues: " + request.getUrl(); Log.info(XhrWarden.class, message); } @Override public void onWarning(WardenRequestManager manager) { Log.info(XhrWarden.class, "Warden Warning -- Too Many Open Requests."); manager.dumpRequestsToConsole(); } } public WardenImpl(int requestWarningLimit, int requestErrorLimit) { this(requestWarningLimit, requestErrorLimit, new WardenEventHandler()); } public WardenImpl(int requestWarningLimit, int requestErrorLimit, WardenListener listener) { this.requestWarningLimit = requestWarningLimit; this.requestErrorLimit = requestErrorLimit; openXhrRequests = JsonCollections.createMap(); customHeaders = JsonCollections.createMap(); alreadyLoggedError = false; eventListener = listener; } @Override public int getRequestCount() { return openXhrRequests.getKeys().size(); } @Override public void iterate(IterationCallback<WardenXhrRequest> callback) { openXhrRequests.iterate(callback); } public WardenXhrRequest getLongestIdleRequest() { WardenXhrRequest oldest = null; for (int i = 0; i < openXhrRequests.getKeys().size(); i++) { WardenXhrRequest wrapper = openXhrRequests.get(openXhrRequests.getKeys().get(i)); if (oldest == null || oldest.getTime() > wrapper.getTime()) { oldest = wrapper; } } return oldest; } @Override public void dumpRequestsToConsole() { final StringBuilder builder = new StringBuilder(); builder.append("\n -- "); builder.append(getRequestCount()); builder.append(" Open XHR Request(s) --\n"); iterate(new IterationCallback<WardenXhrRequest>() { @Override public void onIteration(String key, WardenXhrRequest value) { builder.append('('); builder.append(key); builder.append(") "); builder.append(value.getUrl()); builder.append(" -- last activity on "); builder.append(value.getDateString()); builder.append('\n'); } }); Log.info(getClass(), builder.toString()); } @Override public void onRequestOpen(WardenXhrRequest request) { if (openXhrRequests.get(request.getId()) != null) { // strange state to be in return; } openXhrRequests.put(request.getId(), request); /* * If we haven't notified the server of our state we will let them know * now. In an effort to not flood ourselves we will only re-notify them * once we go back below the warning threshold. */ if (getRequestCount() >= requestWarningLimit && !alreadyLoggedError) { eventListener.onWarning(this); alreadyLoggedError = true; } final XMLHttpRequest xhr = request.getRequest(); customHeaders.iterate(new IterationCallback<String>() { @Override public void onIteration(String header, String value) { xhr.setRequestHeader(header, value); } }); } @Override public void onRequestOpening(WardenXhrRequest request) { /* * We are trying to open up a new xhr request but are at the limit we will * kill the oldest inactive request so that we can make room */ if (openXhrRequests.get(request.getId()) == null && getRequestCount() >= requestErrorLimit) { WardenXhrRequest oldest = getLongestIdleRequest(); oldest.kill(); openXhrRequests.remove(oldest.getId()); eventListener.onEmergency(this, oldest); } } @Override public void onRequestDone(WardenXhrRequest request) { if (openXhrRequests.get(request.getId()) != null) { openXhrRequests.remove(request.getId()); } if (getRequestCount() < requestWarningLimit) { alreadyLoggedError = false; } } @Override public void doListRequests() { dumpRequestsToConsole(); } @Override public void addCustomHeader(String header, String value) { customHeaders.put(header, value); } } /** * Models a warden HTTP request which effectively wraps a XMLHttpRequest */ public interface WardenXhrRequest { /** * Kills the request immediately. */ public void kill(); /** * Retrieves the time of the last activity for this request. */ public double getTime(); /** * Returns the date and time of this requests last activity as a String. */ public String getDateString(); /** * Gets the id for this request that was assigned by the warden. */ public String getId(); /** * Gets the URL this request tried to open. */ public String getUrl(); /** * Retrieves the underlying XMLHttpRequest. */ public XMLHttpRequest getRequest(); } /** * Wraps a warden jso which is essentially an XMLHttpRequest plus a few * special properties. * */ static class WardenXhrRequestImpl extends JavaScriptObject implements WardenXhrRequest { protected WardenXhrRequestImpl() { } public final native void kill() /*-{ this.abort(); }-*/; public final native double getTime() /*-{ return this.wardenTime; }-*/; public final native String getDateString() /*-{ return new Date(this.wardenTime).toString(); }-*/; public final native String getId() /*-{ return "" + this.wardenId; }-*/; public final native String getUrl() /*-{ // Devmode optimization (Browser Channel Weirdness) if (typeof this.wardenUrl != "string") { return "Browser Channel"; } return this.wardenUrl; }-*/; @Override public final native XMLHttpRequest getRequest() /*-{ return this; }-*/; } static WardenRequestManager getInstance() { return wardenManager; } static void setInstance(WardenImpl manager) { wardenManager = manager; } /** * If the warden is currently enabled, it will disable the warden; otherwise, * this is a no-op. */ public static void stopWatching() { wardenManager = null; removeWarden(); } /** * Dumps a list of open requests to the console. */ public static void dumpRequestsToConsole() { if (getInstance() != null) { getInstance().dumpRequestsToConsole(); } } /** * Creates the warden and substitutes it for the XMLHttpRequest object. * Basically creates an XMLHttpRequest factory with an automatic event * listener for onreadystatechange and an overridden open function. This * proved one of the few fully working ways to override the native object. */ private static native void createWarden(WardenReadyStateHandler handler) /*-{ $wnd.XMLHttpRequest = (function(handler) { var requestid = 0; var xmlhttp = $wnd.xmlhttp = $wnd.XMLHttpRequest; return function() { var request = new xmlhttp(); request.wardenId = requestid++; request.addEventListener("readystatechange", function() { // On Open if (this.readyState == 1) { handler. @com.google.collide.client.xhrmonitor.XhrWarden.WardenReadyStateHandler::onRequestOpen(Lcom/google/collide/client/xhrmonitor/XhrWarden$WardenXhrRequest;) (this); } else if (this.readyState == 3) { // this indicates progress so a send or a receive this.wardenTime = (new Date()).getTime(); } else if (this.readyState == 4) { // indicates we ended due to failure or otherwise handler. @com.google.collide.client.xhrmonitor.XhrWarden.WardenReadyStateHandler::onRequestDone(Lcom/google/collide/client/xhrmonitor/XhrWarden$WardenXhrRequest;) (this); } }, true); // Override the xml http request open command request.xhrOpen = request.open; request.open = function(method, url) { this.wardenUrl = url; this.wardenTime = (new Date()).getTime(); handler. @com.google.collide.client.xhrmonitor.XhrWarden.WardenReadyStateHandler::onRequestOpening(Lcom/google/collide/client/xhrmonitor/XhrWarden$WardenXhrRequest;) (this); this.xhrOpen.apply(this,arguments); }; return request; } })(handler); // Calls the warden to list any open xhr requests $wnd.XMLHttpRequest.list = function() { handler. @com.google.collide.client.xhrmonitor.XhrWarden.WardenReadyStateHandler::doListRequests()(); }; if ($wnd.console && $wnd.console.info) { $wnd.console.info("The warden is watching."); } }-*/; private static native void removeWarden() /*-{ $wnd.XMLHttpRequest = $wnd.xmlhttp || $wnd.XMLHttpRequest; }-*/; }