From 7893b7733b7190e820dea28c6ea1bb4629ca1d88 Mon Sep 17 00:00:00 2001 From: Theodore Abshire Date: Sat, 24 Jul 2021 01:39:45 -0700 Subject: [PATCH] Feat(player): Added events for download lifecycle. This adds two new player events, 'downloadheadersreceived' and 'downloadfailed', to allow users to measure network performance in greater detail. Issue #3533 Change-Id: I33a3bd411d815e926d4bea2184e8d3ea69e2bb49 --- externs/shaka/net.js | 21 ++++++-- lib/net/http_fetch_plugin.js | 38 ++++++++++---- lib/net/http_xhr_plugin.js | 50 +++++++++++++----- lib/net/networking_engine.js | 99 +++++++++++++++++++++++++++++------- lib/player.js | 54 +++++++++++++++++++- test/net/http_plugin_unit.js | 35 +++++++------ 6 files changed, 234 insertions(+), 63 deletions(-) diff --git a/externs/shaka/net.js b/externs/shaka/net.js index 68540a5d00..8a70ffb978 100644 --- a/externs/shaka/net.js +++ b/externs/shaka/net.js @@ -139,15 +139,17 @@ shaka.extern.Response; * @typedef {!function(string, * shaka.extern.Request, * shaka.net.NetworkingEngine.RequestType, - * shaka.extern.ProgressUpdated): + * shaka.extern.ProgressUpdated, + * shaka.extern.HeadersReceived): * !shaka.extern.IAbortableOperation.} * @description * Defines a plugin that handles a specific scheme. * * The functions accepts four parameters, uri string, request, request type, - * and a progressUpdated function. The progressUpdated function can be ignored - * by plugins that do not have this information, but it will always be provided - * by NetworkingEngine. + * a progressUpdated function, and a headersReceived function. The + * progressUpdated and headersReceived functions can be ignored by plugins that + * do not have this information, but it will always be provided by + * NetworkingEngine. * * @exportDoc */ @@ -171,6 +173,17 @@ shaka.extern.SchemePlugin; shaka.extern.ProgressUpdated; +/** + * @typedef {function(!Object.)} + * + * @description + * A callback function to handle headers received events through networking + * engine in player. + * The first argument is the headers object of the response. + */ +shaka.extern.HeadersReceived; + + /** * Defines a filter for requests. This filter takes the request and modifies * it before it is sent to the scheme plugin. diff --git a/lib/net/http_fetch_plugin.js b/lib/net/http_fetch_plugin.js index 454b4c2c5e..c4183f0c71 100644 --- a/lib/net/http_fetch_plugin.js +++ b/lib/net/http_fetch_plugin.js @@ -27,10 +27,12 @@ shaka.net.HttpFetchPlugin = class { * @param {shaka.net.NetworkingEngine.RequestType} requestType * @param {shaka.extern.ProgressUpdated} progressUpdated Called when a * progress event happened. + * @param {shaka.extern.HeadersReceived} headersReceived Called when the + * headers for the download are received, but before the body is. * @return {!shaka.extern.IAbortableOperation.} * @export */ - static parse(uri, request, requestType, progressUpdated) { + static parse(uri, request, requestType, progressUpdated, headersReceived) { const headers = new shaka.net.HttpFetchPlugin.Headers_(); shaka.util.MapUtils.asMap(request.headers).forEach((value, key) => { headers.append(key, value); @@ -55,7 +57,7 @@ shaka.net.HttpFetchPlugin = class { }; const pendingRequest = shaka.net.HttpFetchPlugin.request_( - uri, requestType, init, abortStatus, progressUpdated, + uri, requestType, init, abortStatus, progressUpdated, headersReceived, request.streamDataCallback); /** @type {!shaka.util.AbortableOperation} */ @@ -92,12 +94,13 @@ shaka.net.HttpFetchPlugin = class { * @param {!RequestInit} init * @param {shaka.net.HttpFetchPlugin.AbortStatus} abortStatus * @param {shaka.extern.ProgressUpdated} progressUpdated + * @param {shaka.extern.HeadersReceived} headersReceived * @param {?function(BufferSource):!Promise} streamDataCallback * @return {!Promise} * @private */ static async request_(uri, requestType, init, abortStatus, progressUpdated, - streamDataCallback) { + headersReceived, streamDataCallback) { const fetch = shaka.net.HttpFetchPlugin.fetch_; const ReadableStream = shaka.net.HttpFetchPlugin.ReadableStream_; let response; @@ -113,6 +116,10 @@ shaka.net.HttpFetchPlugin = class { // headers are available. The download itself isn't done until the promise // for retrieving the data (arrayBuffer, blob, etc) has resolved. response = await fetch(uri, init); + // At this point in the process, we have the headers of the response, but + // not the body yet. + headersReceived(shaka.net.HttpFetchPlugin.headersToGenericObject_( + response.headers)); // Getting the reader in this way allows us to observe the process of // downloading the body, instead of just waiting for an opaque promise to // resolve. @@ -194,19 +201,28 @@ shaka.net.HttpFetchPlugin = class { } } - const headers = {}; - /** @type {Headers} */ - const responseHeaders = response.headers; - responseHeaders.forEach((value, key) => { - // Since Edge incorrectly return the header with a leading new line - // character ('\n'), we trim the header here. - headers[key.trim()] = value; - }); + const headers = shaka.net.HttpFetchPlugin.headersToGenericObject_( + response.headers); return shaka.net.HttpPluginUtils.makeResponse( headers, arrayBuffer, response.status, uri, response.url, requestType); } + /** + * @param {!Headers} headers + * @return {!Object.} + * @private + */ + static headersToGenericObject_(headers) { + const headersObj = {}; + headers.forEach((value, key) => { + // Since Edge incorrectly return the header with a leading new line + // character ('\n'), we trim the header here. + headersObj[key.trim()] = value; + }); + return headersObj; + } + /** * Determine if the Fetch API is supported in the browser. Note: this is * deliberately exposed as a method to allow the client app to use the same diff --git a/lib/net/http_xhr_plugin.js b/lib/net/http_xhr_plugin.js index b55995ccc7..ece2b6c96f 100644 --- a/lib/net/http_xhr_plugin.js +++ b/lib/net/http_xhr_plugin.js @@ -24,10 +24,12 @@ shaka.net.HttpXHRPlugin = class { * @param {shaka.net.NetworkingEngine.RequestType} requestType * @param {shaka.extern.ProgressUpdated} progressUpdated Called when a * progress event happened. + * @param {shaka.extern.HeadersReceived} headersReceived Called when the + * headers for the download are received, but before the body is. * @return {!shaka.extern.IAbortableOperation.} * @export */ - static parse(uri, request, requestType, progressUpdated) { + static parse(uri, request, requestType, progressUpdated, headersReceived) { const xhr = new shaka.net.HttpXHRPlugin.Xhr_(); // Last time stamp when we got a progress event. @@ -48,23 +50,25 @@ shaka.net.HttpXHRPlugin = class { shaka.util.Error.Code.OPERATION_ABORTED, uri, requestType)); }; - xhr.onload = (event) => { - const target = event.target; - goog.asserts.assert(target, 'XHR onload has no target!'); - // Since Edge incorrectly return the header with a leading new - // line character ('\n'), we trim the header here. - const headerLines = target.getAllResponseHeaders().trim().split('\r\n'); - const headers = {}; - for (const header of headerLines) { - /** @type {!Array.} */ - const parts = header.split(': '); - headers[parts[0].toLowerCase()] = parts.slice(1).join(': '); + let calledHeadersReceived = false; + xhr.onreadystatechange = (event) => { + // See if the readyState is 2 ("HEADERS_RECEIVED"). + if (xhr.readyState == 2 && !calledHeadersReceived) { + const headers = shaka.net.HttpXHRPlugin.headersToGenericObject_(xhr); + headersReceived(headers); + // Don't send out this event twice. + calledHeadersReceived = true; } + }; + xhr.onload = (event) => { + const headers = shaka.net.HttpXHRPlugin.headersToGenericObject_(xhr); + goog.asserts.assert(xhr.response instanceof ArrayBuffer, + 'XHR should have a response by now!'); + const xhrResponse = xhr.response; try { const response = shaka.net.HttpPluginUtils.makeResponse(headers, - target.response, target.status, uri, target.responseURL, - requestType); + xhrResponse, xhr.status, uri, xhr.responseURL, requestType); resolve(response); } catch (error) { goog.asserts.assert(error instanceof shaka.util.Error, @@ -116,6 +120,24 @@ shaka.net.HttpXHRPlugin = class { return Promise.resolve(); }); } + + /** + * @param {!XMLHttpRequest} xhr + * @return {!Object.} + * @private + */ + static headersToGenericObject_(xhr) { + // Since Edge incorrectly return the header with a leading new + // line character ('\n'), we trim the header here. + const headerLines = xhr.getAllResponseHeaders().trim().split('\r\n'); + const headers = {}; + for (const header of headerLines) { + /** @type {!Array.} */ + const parts = header.split(': '); + headers[parts[0].toLowerCase()] = parts.slice(1).join(': '); + } + return headers; + } }; diff --git a/lib/net/networking_engine.js b/lib/net/networking_engine.js index da5a5c23b8..5bdf87e7de 100644 --- a/lib/net/networking_engine.js +++ b/lib/net/networking_engine.js @@ -48,8 +48,12 @@ shaka.net.NetworkingEngine = class extends shaka.util.FakeEventTarget { * @param {function(number, number)=} onProgressUpdated Called when a progress * event is triggered. Passed the duration, in milliseconds, that the * request took, and the number of bytes transferred. + * @param {shaka.net.NetworkingEngine.OnHeadersReceived=} onHeadersReceived + * Called when the headers are received for a download. + * @param {shaka.net.NetworkingEngine.OnDownloadFailed=} onDownloadFailed + * Called when a download fails, for any reason. */ - constructor(onProgressUpdated) { + constructor(onProgressUpdated, onHeadersReceived, onDownloadFailed) { super(); /** @private {boolean} */ @@ -67,6 +71,12 @@ shaka.net.NetworkingEngine = class extends shaka.util.FakeEventTarget { /** @private {?function(number, number)} */ this.onProgressUpdated_ = onProgressUpdated || null; + /** @private {?shaka.net.NetworkingEngine.OnHeadersReceived} */ + this.onHeadersReceived_ = onHeadersReceived || null; + + /** @private {?shaka.net.NetworkingEngine.OnDownloadFailed} */ + this.onDownloadFailed_ = onDownloadFailed || null; + /** @private {boolean} */ this.forceHTTPS_ = false; } @@ -447,6 +457,8 @@ shaka.net.NetworkingEngine = class extends shaka.util.FakeEventTarget { let aborted = false; + let headersReceivedCalled = false; + let startTimeMs; const sendOperation = backoffOperation.chain(() => { if (this.destroyed_) { @@ -456,23 +468,27 @@ shaka.net.NetworkingEngine = class extends shaka.util.FakeEventTarget { startTimeMs = Date.now(); const segment = shaka.net.NetworkingEngine.RequestType.SEGMENT; - const requestPlugin = plugin(request.uris[index], - request, - type, - // The following function is passed to plugin. - (time, bytes, numBytesRemaining) => { - if (connectionTimer) { - connectionTimer.stop(); - } - if (stallTimer) { - stallTimer.tickAfter(stallTimeoutMs / 1000); - } - if (this.onProgressUpdated_ && type == segment) { - this.onProgressUpdated_(time, bytes); - gotProgress = true; - numBytesRemainingObj.setBytes(numBytesRemaining); - } - }); + const progressUpdated = (time, bytes, numBytesRemaining) => { + if (connectionTimer) { + connectionTimer.stop(); + } + if (stallTimer) { + stallTimer.tickAfter(stallTimeoutMs / 1000); + } + if (this.onProgressUpdated_ && type == segment) { + this.onProgressUpdated_(time, bytes); + gotProgress = true; + numBytesRemainingObj.setBytes(numBytesRemaining); + } + }; + const headersReceived = (headers) => { + if (this.onHeadersReceived_) { + this.onHeadersReceived_(headers, request, type); + } + headersReceivedCalled = true; + }; + const requestPlugin = plugin( + request.uris[index], request, type, progressUpdated, headersReceived); if (!progressSupport) { return requestPlugin; @@ -511,6 +527,13 @@ shaka.net.NetworkingEngine = class extends shaka.util.FakeEventTarget { response: response, gotProgress: gotProgress, }; + if (!headersReceivedCalled) { + // The plugin did not call headersReceived, perhaps because it is not + // able to track that information. So, fire the event manually. + if (this.onHeadersReceived_) { + this.onHeadersReceived_(response.headers, request, type); + } + } return responseAndGotProgress; }, (error) => { @@ -520,6 +543,17 @@ shaka.net.NetworkingEngine = class extends shaka.util.FakeEventTarget { if (stallTimer) { stallTimer.stop(); } + if (this.onDownloadFailed_) { + let shakaError = null; + let httpResponseCode = 0; + if (error instanceof shaka.util.Error) { + shakaError = error; + if (error.code == shaka.util.Error.Code.BAD_HTTP_STATUS) { + httpResponseCode = /** @type {number} */ (error.data[1]); + } + } + this.onDownloadFailed_(request, shakaError, httpResponseCode, aborted); + } if (this.destroyed_) { return shaka.util.AbortableOperation.aborted(); } @@ -763,3 +797,32 @@ shaka.net.NetworkingEngine.schemes_ = {}; * @private */ shaka.net.NetworkingEngine.ResponseAndGotProgress; + + +/** + * @typedef {function( + * !Object., + * !shaka.extern.Request, + * !shaka.net.NetworkingEngine.RequestType)} + * + * @description + * A callback function that passes the shaka.extern.HeadersReceived along to + * the player, plus some extra data. + * @export + */ +shaka.net.NetworkingEngine.OnHeadersReceived; + + +/** + * @typedef {function( + * !shaka.extern.Request, + * ?shaka.util.Error, + * number, + * boolean)} + * + * @description + * A callback function that notifies the player when a download fails, for any + * reason (e.g. even if the download was aborted). + * @export + */ +shaka.net.NetworkingEngine.OnDownloadFailed; diff --git a/lib/player.js b/lib/player.js index 4dd4b95152..a1eec70e5a 100644 --- a/lib/player.js +++ b/lib/player.js @@ -106,6 +106,32 @@ goog.requireType('shaka.routing.Payload'); */ +/** + * @event shaka.Player.DownloadFailed + * @description Fired when a download has failed, for any reason. + * 'downloadfailed' + * @property {!shaka.extern.Request} request + * @property {?shaka.util.Error} error + * @param {number} httpResponseCode + * @param {boolean} aborted + * @exportDoc + */ + + +/** + * @event shaka.Player.DownloadHeadersReceived + * @description Fired when the networking engine has received the headers for + * a download, but before the body has been downloaded. + * If the HTTP plugin being used does not track this information, this event + * will default to being fired when the body is received, instead. + * @property {!Object.} headers + * @property {!shaka.extern.Request} request + * @property {!shaka.net.NetworkingEngine.RequestType} type + * 'downloadheadersreceived' + * @exportDoc + */ + + /** * @event shaka.Player.DrmSessionUpdateEvent * @description Fired when the CDM has accepted the license response. @@ -2507,8 +2533,32 @@ shaka.Player = class extends shaka.util.FakeEventTarget { this.abrManager_.segmentDownloaded(deltaTimeMs, bytesDownloaded); } }; + /** @type {shaka.net.NetworkingEngine.OnHeadersReceived} */ + const onHeadersReceived_ = (headers, request, requestType) => { + // Release a 'downloadheadersreceived' event. + const name = shaka.Player.EventName.DownloadHeadersReceived; + const data = { + headers, + request, + requestType, + }; + this.dispatchEvent(this.makeEvent_(name, data)); + }; + /** @type {shaka.net.NetworkingEngine.OnDownloadFailed} */ + const onDownloadFailed_ = (request, error, httpResponseCode, aborted) => { + // Release a 'downloadfailed' event. + const name = shaka.Player.EventName.DownloadFailed; + const data = { + request, + error, + httpResponseCode, + aborted, + }; + this.dispatchEvent(this.makeEvent_(name, data)); + }; - return new shaka.net.NetworkingEngine(onProgressUpdated_); + return new shaka.net.NetworkingEngine( + onProgressUpdated_, onHeadersReceived_, onDownloadFailed_); } /** @@ -6222,6 +6272,8 @@ shaka.Player.EventName = { AbrStatusChanged: 'abrstatuschanged', Adaptation: 'adaptation', Buffering: 'buffering', + DownloadFailed: 'downloadfailed', + DownloadHeadersReceived: 'downloadheadersreceived', DrmSessionUpdate: 'drmsessionupdate', Emsg: 'emsg', Error: 'error', diff --git a/test/net/http_plugin_unit.js b/test/net/http_plugin_unit.js index 15c9fbd486..04c264683b 100644 --- a/test/net/http_plugin_unit.js +++ b/test/net/http_plugin_unit.js @@ -26,6 +26,9 @@ function httpPluginTests(usingFetch) { // A dummy progress callback. const progressUpdated = (elapsedMs, bytes, bytesRemaining) => {}; + // A dummy headers callback. + const headersReceived = (headers) => {}; + /** @type {shaka.extern.RetryParameters} */ let retryParameters; @@ -168,7 +171,8 @@ function httpPluginTests(usingFetch) { request.method = 'POST'; request.headers['BAZ'] = '123'; - await plugin(request.uris[0], request, requestType, progressUpdated) + await plugin( + request.uris[0], request, requestType, progressUpdated, headersReceived) .promise; const actual = mostRecentRequest(); @@ -189,8 +193,8 @@ function httpPluginTests(usingFetch) { request.body = null; request.method = 'GET'; - await plugin(request.uris[0], request, requestType, progressUpdated) - .promise; + await plugin(request.uris[0], request, requestType, progressUpdated, + headersReceived).promise; const actual = jasmine.Fetch.requests.mostRecent(); expect(actual).toBeTruthy(); @@ -205,8 +209,8 @@ function httpPluginTests(usingFetch) { const request = shaka.net.NetworkingEngine.makeRequest( [uri], retryParameters, Util.spyFunc(streamDataCallback)); - const response = - await plugin(uri, request, requestType, progressUpdated).promise; + const response = await plugin( + uri, request, requestType, progressUpdated, headersReceived).promise; expect(mostRecentRequest().url).toBe(uri); expect(response).toBeTruthy(); @@ -297,9 +301,9 @@ function httpPluginTests(usingFetch) { const request = shaka.net.NetworkingEngine.makeRequest( ['https://foo.bar/cache'], retryParameters); - const response = - await plugin(request.uris[0], request, requestType, progressUpdated) - .promise; + const response = await plugin( + request.uris[0], request, requestType, progressUpdated, headersReceived) + .promise; expect(response).toBeTruthy(); expect(response.fromCache).toBe(true); }); @@ -312,8 +316,8 @@ function httpPluginTests(usingFetch) { uri = 'https://foo.bar/timeout'; const request = shaka.net.NetworkingEngine.makeRequest( [uri], retryParameters); - const operation = plugin( - request.uris[0], request, requestType, progressUpdated); + const operation = plugin(request.uris[0], request, requestType, + progressUpdated, headersReceived); /** @type {jasmine.Fetch.RequestStub} */ const actual = jasmine.Fetch.requests.mostRecent(); @@ -354,8 +358,8 @@ function httpPluginTests(usingFetch) { uri = 'https://foo.bar/'; const request = shaka.net.NetworkingEngine.makeRequest( [uri], retryParameters); - operation = plugin( - request.uris[0], request, requestType, progressUpdated); + operation = plugin(request.uris[0], request, requestType, progressUpdated, + headersReceived); requestPromise = operation.promise; } @@ -388,8 +392,8 @@ function httpPluginTests(usingFetch) { async function testSucceeds(uri, overrideUri) { const request = shaka.net.NetworkingEngine.makeRequest( [uri], retryParameters); - const response = - await plugin(uri, request, requestType, progressUpdated).promise; + const response = await plugin( + uri, request, requestType, progressUpdated, headersReceived).promise; expect(mostRecentRequest().url).toBe(uri); expect(response).toBeTruthy(); @@ -410,7 +414,8 @@ function httpPluginTests(usingFetch) { const request = shaka.net.NetworkingEngine.makeRequest( [uri], retryParameters); - const p = plugin(uri, request, requestType, progressUpdated).promise; + const p = plugin( + uri, request, requestType, progressUpdated, headersReceived).promise; if (expected.code == shaka.util.Error.Code.TIMEOUT) { jasmine.clock().tick(5000); }