fune/devtools/shared/webconsole/throttle.js

469 lines
13 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const { CC, Ci, Cc } = require("chrome");
const ArrayBufferInputStream = CC(
"@mozilla.org/io/arraybuffer-input-stream;1",
"nsIArrayBufferInputStream"
);
const BinaryInputStream = CC(
"@mozilla.org/binaryinputstream;1",
"nsIBinaryInputStream",
"setInputStream"
);
loader.lazyServiceGetter(
this,
"gActivityDistributor",
"@mozilla.org/network/http-activity-distributor;1",
"nsIHttpActivityDistributor"
);
const ChromeUtils = require("ChromeUtils");
const { setTimeout } = require("resource://gre/modules/Timer.jsm");
/**
* Construct a new nsIStreamListener that buffers data and provides a
* method to notify another listener when data is available. This is
* used to throttle network data on a per-channel basis.
*
* After construction, @see setOriginalListener must be called on the
* new object.
*
* @param {NetworkThrottleQueue} queue the NetworkThrottleQueue to
* which status changes should be reported
*/
function NetworkThrottleListener(queue) {
this.queue = queue;
this.pendingData = [];
this.pendingException = null;
this.offset = 0;
this.responseStarted = false;
this.activities = {};
}
NetworkThrottleListener.prototype = {
QueryInterface: ChromeUtils.generateQI([
"nsIStreamListener",
"nsIInterfaceRequestor",
]),
/**
* Set the original listener for this object. The original listener
* will receive requests from this object when the queue allows data
* through.
*
* @param {nsIStreamListener} originalListener the original listener
* for the channel, to which all requests will be sent
*/
setOriginalListener: function(originalListener) {
this.originalListener = originalListener;
},
/**
* @see nsIStreamListener.onStartRequest.
*/
onStartRequest: function(request) {
this.originalListener.onStartRequest(request);
this.queue.start(this);
},
/**
* @see nsIStreamListener.onStopRequest.
*/
onStopRequest: function(request, statusCode) {
this.pendingData.push({ request, statusCode });
this.queue.dataAvailable(this);
},
/**
* @see nsIStreamListener.onDataAvailable.
*/
onDataAvailable: function(request, inputStream, offset, count) {
if (this.pendingException) {
throw this.pendingException;
}
const bin = new BinaryInputStream(inputStream);
const bytes = new ArrayBuffer(count);
bin.readArrayBuffer(count, bytes);
const stream = new ArrayBufferInputStream();
stream.setData(bytes, 0, count);
this.pendingData.push({ request, stream, count });
this.queue.dataAvailable(this);
},
/**
* Allow some buffered data from this object to be forwarded to this
* object's originalListener.
*
* @param {Number} bytesPermitted The maximum number of bytes
* permitted to be sent.
* @return {Object} an object of the form {length, done}, where
* |length| is the number of bytes actually forwarded, and
* |done| is a boolean indicating whether this particular
* request has been completed. (A NetworkThrottleListener
* may be queued multiple times, so this does not mean that
* all available data has been sent.)
*/
sendSomeData: function(bytesPermitted) {
if (this.pendingData.length === 0) {
// Shouldn't happen.
return { length: 0, done: true };
}
const { request, stream, count, statusCode } = this.pendingData[0];
if (statusCode !== undefined) {
this.pendingData.shift();
this.originalListener.onStopRequest(request, statusCode);
return { length: 0, done: true };
}
if (bytesPermitted > count) {
bytesPermitted = count;
}
try {
this.originalListener.onDataAvailable(
request,
stream,
this.offset,
bytesPermitted
);
} catch (e) {
this.pendingException = e;
}
let done = false;
if (bytesPermitted === count) {
this.pendingData.shift();
done = true;
} else {
this.pendingData[0].count -= bytesPermitted;
}
this.offset += bytesPermitted;
// Maybe our state has changed enough to emit an event.
this.maybeEmitEvents();
return { length: bytesPermitted, done };
},
/**
* Return the number of pending data requests available for this
* listener.
*/
pendingCount: function() {
return this.pendingData.length;
},
/**
* This is called when an http activity event is delivered. This
* object delays the event until the appropriate moment.
*/
addActivityCallback: function(
callback,
httpActivity,
channel,
activityType,
activitySubtype,
timestamp,
extraSizeData,
extraStringData
) {
const datum = {
callback,
httpActivity,
channel,
activityType,
activitySubtype,
extraSizeData,
extraStringData,
};
this.activities[activitySubtype] = datum;
if (
activitySubtype ===
gActivityDistributor.ACTIVITY_SUBTYPE_RESPONSE_COMPLETE
) {
this.totalSize = extraSizeData;
}
this.maybeEmitEvents();
},
/**
* This is called for a download throttler when the latency timeout
* has ended.
*/
responseStart: function() {
this.responseStarted = true;
this.maybeEmitEvents();
},
/**
* Check our internal state and emit any http activity events as
* needed. Note that we wait until both our internal state has
* changed and we've received the real http activity event from
* platform. This approach ensures we can both pass on the correct
* data from the original event, and update the reported time to be
* consistent with the delay we're introducing.
*/
maybeEmitEvents: function() {
if (this.responseStarted) {
this.maybeEmit(gActivityDistributor.ACTIVITY_SUBTYPE_RESPONSE_START);
this.maybeEmit(gActivityDistributor.ACTIVITY_SUBTYPE_RESPONSE_HEADER);
}
if (this.totalSize !== undefined && this.offset >= this.totalSize) {
this.maybeEmit(gActivityDistributor.ACTIVITY_SUBTYPE_RESPONSE_COMPLETE);
this.maybeEmit(gActivityDistributor.ACTIVITY_SUBTYPE_TRANSACTION_CLOSE);
}
},
/**
* Emit an event for |code|, if the appropriate entry in
* |activities| is defined.
*/
maybeEmit: function(code) {
if (this.activities[code] !== undefined) {
const {
callback,
httpActivity,
channel,
activityType,
activitySubtype,
extraSizeData,
extraStringData,
} = this.activities[code];
const now = Date.now() * 1000;
callback(
httpActivity,
channel,
activityType,
activitySubtype,
now,
extraSizeData,
extraStringData
);
this.activities[code] = undefined;
}
},
};
/**
* Construct a new queue that can be used to throttle the network for
* a group of related network requests.
*
* meanBPS {Number} Mean bytes per second.
* maxBPS {Number} Maximum bytes per second.
* latencyMean {Number} Mean latency in milliseconds.
* latencyMax {Number} Maximum latency in milliseconds.
*/
function NetworkThrottleQueue(meanBPS, maxBPS, latencyMean, latencyMax) {
this.meanBPS = meanBPS;
this.maxBPS = maxBPS;
this.latencyMean = latencyMean;
this.latencyMax = latencyMax;
this.pendingRequests = new Set();
this.downloadQueue = [];
this.previousReads = [];
this.pumping = false;
}
NetworkThrottleQueue.prototype = {
/**
* A helper function that, given a mean and a maximum, returns a
* random integer between (mean - (max - mean)) and max.
*/
random: function(mean, max) {
return mean - (max - mean) + Math.floor(2 * (max - mean) * Math.random());
},
/**
* A helper function that lets the indicating listener start sending
* data. This is called after the initial round trip time for the
* listener has elapsed.
*/
allowDataFrom: function(throttleListener) {
throttleListener.responseStart();
this.pendingRequests.delete(throttleListener);
const count = throttleListener.pendingCount();
for (let i = 0; i < count; ++i) {
this.downloadQueue.push(throttleListener);
}
this.pump();
},
/**
* Notice a new listener object. This is called by the
* NetworkThrottleListener when the request has started. Initially
* a new listener object is put into a "pending" state, until the
* round-trip time has elapsed. This is used to simulate latency.
*
* @param {NetworkThrottleListener} throttleListener the new listener
*/
start: function(throttleListener) {
this.pendingRequests.add(throttleListener);
const delay = this.random(this.latencyMean, this.latencyMax);
if (delay > 0) {
setTimeout(() => this.allowDataFrom(throttleListener), delay);
} else {
this.allowDataFrom(throttleListener);
}
},
/**
* Note that new data is available for a given listener. Each time
* data is available, the listener will be re-queued.
*
* @param {NetworkThrottleListener} throttleListener the listener
* which has data available.
*/
dataAvailable: function(throttleListener) {
if (!this.pendingRequests.has(throttleListener)) {
this.downloadQueue.push(throttleListener);
this.pump();
}
},
/**
* An internal function that permits individual listeners to send
* data.
*/
pump: function() {
// A redirect will cause two NetworkThrottleListeners to be on a
// listener chain. In this case, we might recursively call into
// this method. Avoid infinite recursion here.
if (this.pumping) {
return;
}
this.pumping = true;
const now = Date.now();
const oneSecondAgo = now - 1000;
while (
this.previousReads.length &&
this.previousReads[0].when < oneSecondAgo
) {
this.previousReads.shift();
}
const totalBytes = this.previousReads.reduce((sum, elt) => {
return sum + elt.numBytes;
}, 0);
let thisSliceBytes = this.random(this.meanBPS, this.maxBPS);
if (totalBytes < thisSliceBytes) {
thisSliceBytes -= totalBytes;
let readThisTime = 0;
while (thisSliceBytes > 0 && this.downloadQueue.length) {
const { length, done } = this.downloadQueue[0].sendSomeData(
thisSliceBytes
);
thisSliceBytes -= length;
readThisTime += length;
if (done) {
this.downloadQueue.shift();
}
}
this.previousReads.push({ when: now, numBytes: readThisTime });
}
// If there is more data to download, then schedule ourselves for
// one second after the oldest previous read.
if (this.downloadQueue.length) {
const when = this.previousReads[0].when + 1000;
setTimeout(this.pump.bind(this), when - now);
}
this.pumping = false;
},
};
/**
* Construct a new object that can be used to throttle the network for
* a group of related network requests.
*
* @param {Object} An object with the following attributes:
* latencyMean {Number} Mean latency in milliseconds.
* latencyMax {Number} Maximum latency in milliseconds.
* downloadBPSMean {Number} Mean bytes per second for downloads.
* downloadBPSMax {Number} Maximum bytes per second for downloads.
* uploadBPSMean {Number} Mean bytes per second for uploads.
* uploadBPSMax {Number} Maximum bytes per second for uploads.
*
* Download throttling will not be done if downloadBPSMean and
* downloadBPSMax are <= 0. Upload throttling will not be done if
* uploadBPSMean and uploadBPSMax are <= 0.
*/
function NetworkThrottleManager({
latencyMean,
latencyMax,
downloadBPSMean,
downloadBPSMax,
uploadBPSMean,
uploadBPSMax,
}) {
if (downloadBPSMax <= 0 && downloadBPSMean <= 0) {
this.downloadQueue = null;
} else {
this.downloadQueue = new NetworkThrottleQueue(
downloadBPSMean,
downloadBPSMax,
latencyMean,
latencyMax
);
}
if (uploadBPSMax <= 0 && uploadBPSMean <= 0) {
this.uploadQueue = null;
} else {
this.uploadQueue = Cc[
"@mozilla.org/network/throttlequeue;1"
].createInstance(Ci.nsIInputChannelThrottleQueue);
this.uploadQueue.init(uploadBPSMean, uploadBPSMax);
}
}
exports.NetworkThrottleManager = NetworkThrottleManager;
NetworkThrottleManager.prototype = {
/**
* Create a new NetworkThrottleListener for a given channel and
* install it using |setNewListener|.
*
* @param {nsITraceableChannel} channel the channel to manage
* @return {NetworkThrottleListener} the new listener, or null if
* download throttling is not being done.
*/
manage: function(channel) {
if (this.downloadQueue) {
const listener = new NetworkThrottleListener(this.downloadQueue);
const originalListener = channel.setNewListener(listener);
listener.setOriginalListener(originalListener);
return listener;
}
return null;
},
/**
* Throttle uploads taking place on the given channel.
*
* @param {nsITraceableChannel} channel the channel to manage
*/
manageUpload: function(channel) {
if (this.uploadQueue) {
channel = channel.QueryInterface(Ci.nsIThrottledInputChannel);
channel.throttleQueue = this.uploadQueue;
}
},
};