From 26a029d407be480d791972afb5975cf62c9360a6 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 19 Apr 2024 02:47:55 +0200 Subject: Adding upstream version 124.0.1. Signed-off-by: Daniel Baumann --- netwerk/test/unit/http2_test_common.js | 1504 ++++++++++++++++++++++++++++++++ 1 file changed, 1504 insertions(+) create mode 100644 netwerk/test/unit/http2_test_common.js (limited to 'netwerk/test/unit/http2_test_common.js') diff --git a/netwerk/test/unit/http2_test_common.js b/netwerk/test/unit/http2_test_common.js new file mode 100644 index 0000000000..341aa191da --- /dev/null +++ b/netwerk/test/unit/http2_test_common.js @@ -0,0 +1,1504 @@ +// test HTTP/2 + +"use strict"; + +/* import-globals-from head_channels.js */ + +// Generate a small and a large post with known pre-calculated md5 sums +function generateContent(size) { + var content = ""; + for (var i = 0; i < size; i++) { + content += "0"; + } + return content; +} + +var posts = []; +posts.push(generateContent(10)); +posts.push(generateContent(250000)); +posts.push(generateContent(128000)); + +// pre-calculated md5sums (in hex) of the above posts +var md5s = [ + "f1b708bba17f1ce948dc979f4d7092bc", + "2ef8d3b6c8f329318eb1a119b12622b6", +]; + +var bigListenerData = generateContent(128 * 1024); +var bigListenerMD5 = "8f607cfdd2c87d6a7eedb657dafbd836"; + +function checkIsHttp2(request) { + try { + if (request.getResponseHeader("X-Firefox-Spdy") == "h2") { + if (request.getResponseHeader("X-Connection-Http2") == "yes") { + return true; + } + return false; // Weird case, but the server disagrees with us + } + } catch (e) { + // Nothing to do here + } + return false; +} + +var Http2CheckListener = function () {}; + +Http2CheckListener.prototype = { + onStartRequestFired: false, + onDataAvailableFired: false, + isHttp2Connection: false, + shouldBeHttp2: true, + accum: 0, + expected: -1, + shouldSucceed: true, + + onStartRequest: function testOnStartRequest(request) { + this.onStartRequestFired = true; + if (this.shouldSucceed && !Components.isSuccessCode(request.status)) { + do_throw("Channel should have a success code! (" + request.status + ")"); + } else if ( + !this.shouldSucceed && + Components.isSuccessCode(request.status) + ) { + do_throw("Channel succeeded unexpectedly!"); + } + + Assert.ok(request instanceof Ci.nsIHttpChannel); + Assert.equal(request.requestSucceeded, this.shouldSucceed); + if (this.shouldSucceed) { + Assert.equal(request.responseStatus, 200); + } + }, + + onDataAvailable: function testOnDataAvailable(request, stream, off, cnt) { + this.onDataAvailableFired = true; + this.isHttp2Connection = checkIsHttp2(request); + this.accum += cnt; + read_stream(stream, cnt); + }, + + onStopRequest: function testOnStopRequest(request, status) { + Assert.ok(this.onStartRequestFired); + if (this.expected != -1) { + Assert.equal(this.accum, this.expected); + } + + if (this.shouldSucceed) { + Assert.ok(Components.isSuccessCode(status)); + Assert.ok(this.onDataAvailableFired); + Assert.ok(this.isHttp2Connection == this.shouldBeHttp2); + } else { + Assert.ok(!Components.isSuccessCode(status)); + } + request.QueryInterface(Ci.nsIProxiedChannel); + var httpProxyConnectResponseCode = request.httpProxyConnectResponseCode; + this.finish({ httpProxyConnectResponseCode }); + }, +}; + +/* + * Support for testing valid multiplexing of streams + */ + +var multiplexContent = generateContent(30 * 1024); + +/* Listener class to control the testing of multiplexing */ +var Http2MultiplexListener = function () {}; + +Http2MultiplexListener.prototype = new Http2CheckListener(); + +Http2MultiplexListener.prototype.streamID = 0; +Http2MultiplexListener.prototype.buffer = ""; + +Http2MultiplexListener.prototype.onDataAvailable = function ( + request, + stream, + off, + cnt +) { + this.onDataAvailableFired = true; + this.isHttp2Connection = checkIsHttp2(request); + this.streamID = parseInt(request.getResponseHeader("X-Http2-StreamID")); + var data = read_stream(stream, cnt); + this.buffer = this.buffer.concat(data); +}; + +Http2MultiplexListener.prototype.onStopRequest = function (request, status) { + Assert.ok(this.onStartRequestFired); + Assert.ok(this.onDataAvailableFired); + Assert.ok(this.isHttp2Connection); + Assert.ok(this.buffer == multiplexContent); + + request.QueryInterface(Ci.nsIProxiedChannel); + // This is what does most of the hard work for us + var httpProxyConnectResponseCode = request.httpProxyConnectResponseCode; + var streamID = this.streamID; + this.finish({ httpProxyConnectResponseCode, streamID }); +}; + +// Does the appropriate checks for header gatewaying +var Http2HeaderListener = function (name, callback) { + this.name = name; + this.callback = callback; +}; + +Http2HeaderListener.prototype = new Http2CheckListener(); +Http2HeaderListener.prototype.value = ""; + +Http2HeaderListener.prototype.onDataAvailable = function ( + request, + stream, + off, + cnt +) { + this.onDataAvailableFired = true; + this.isHttp2Connection = checkIsHttp2(request); + var hvalue = request.getResponseHeader(this.name); + Assert.notEqual(hvalue, ""); + this.callback(hvalue); + read_stream(stream, cnt); +}; + +var Http2PushListener = function (shouldBePushed) { + this.shouldBePushed = shouldBePushed; +}; + +Http2PushListener.prototype = new Http2CheckListener(); + +Http2PushListener.prototype.onDataAvailable = function ( + request, + stream, + off, + cnt +) { + this.onDataAvailableFired = true; + this.isHttp2Connection = checkIsHttp2(request); + if ( + request.originalURI.spec == + `https://localhost:${this.serverPort}/push.js` || + request.originalURI.spec == + `https://localhost:${this.serverPort}/push2.js` || + request.originalURI.spec == `https://localhost:${this.serverPort}/push5.js` + ) { + Assert.equal( + request.getResponseHeader("pushed"), + this.shouldBePushed ? "yes" : "no" + ); + } + read_stream(stream, cnt); +}; + +const pushHdrTxt = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; +const pullHdrTxt = pushHdrTxt.split("").reverse().join(""); + +function checkContinuedHeaders(getHeader, headerPrefix, headerText) { + for (var i = 0; i < 265; i++) { + Assert.equal(getHeader(headerPrefix + 1), headerText); + } +} + +var Http2ContinuedHeaderListener = function () {}; + +Http2ContinuedHeaderListener.prototype = new Http2CheckListener(); + +Http2ContinuedHeaderListener.prototype.onStopsLeft = 2; + +Http2ContinuedHeaderListener.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsIHttpPushListener", + "nsIStreamListener", +]); + +Http2ContinuedHeaderListener.prototype.getInterface = function (aIID) { + return this.QueryInterface(aIID); +}; + +Http2ContinuedHeaderListener.prototype.onDataAvailable = function ( + request, + stream, + off, + cnt +) { + this.onDataAvailableFired = true; + this.isHttp2Connection = checkIsHttp2(request); + if ( + request.originalURI.spec == + `https://localhost:${this.serverPort}/continuedheaders` + ) { + // This is the original request, so the only one where we'll have continued response headers + checkContinuedHeaders( + request.getResponseHeader, + "X-Pull-Test-Header-", + pullHdrTxt + ); + } + read_stream(stream, cnt); +}; + +Http2ContinuedHeaderListener.prototype.onStopRequest = function ( + request, + status +) { + Assert.ok(this.onStartRequestFired); + Assert.ok(Components.isSuccessCode(status)); + Assert.ok(this.onDataAvailableFired); + Assert.ok(this.isHttp2Connection); + + --this.onStopsLeft; + if (this.onStopsLeft === 0) { + request.QueryInterface(Ci.nsIProxiedChannel); + var httpProxyConnectResponseCode = request.httpProxyConnectResponseCode; + this.finish({ httpProxyConnectResponseCode }); + } +}; + +Http2ContinuedHeaderListener.prototype.onPush = function ( + associatedChannel, + pushChannel +) { + Assert.equal( + associatedChannel.originalURI.spec, + "https://localhost:" + this.serverPort + "/continuedheaders" + ); + Assert.equal(pushChannel.getRequestHeader("x-pushed-request"), "true"); + checkContinuedHeaders( + pushChannel.getRequestHeader, + "X-Push-Test-Header-", + pushHdrTxt + ); + + pushChannel.asyncOpen(this); +}; + +// Does the appropriate checks for a large GET response +var Http2BigListener = function () {}; + +Http2BigListener.prototype = new Http2CheckListener(); +Http2BigListener.prototype.buffer = ""; + +Http2BigListener.prototype.onDataAvailable = function ( + request, + stream, + off, + cnt +) { + this.onDataAvailableFired = true; + this.isHttp2Connection = checkIsHttp2(request); + this.buffer = this.buffer.concat(read_stream(stream, cnt)); + // We know the server should send us the same data as our big post will be, + // so the md5 should be the same + Assert.equal(bigListenerMD5, request.getResponseHeader("X-Expected-MD5")); +}; + +Http2BigListener.prototype.onStopRequest = function (request, status) { + Assert.ok(this.onStartRequestFired); + Assert.ok(this.onDataAvailableFired); + Assert.ok(this.isHttp2Connection); + + // Don't want to flood output, so don't use do_check_eq + Assert.ok(this.buffer == bigListenerData); + + request.QueryInterface(Ci.nsIProxiedChannel); + var httpProxyConnectResponseCode = request.httpProxyConnectResponseCode; + this.finish({ httpProxyConnectResponseCode }); +}; + +var Http2HugeSuspendedListener = function () {}; + +Http2HugeSuspendedListener.prototype = new Http2CheckListener(); +Http2HugeSuspendedListener.prototype.count = 0; + +Http2HugeSuspendedListener.prototype.onDataAvailable = function ( + request, + stream, + off, + cnt +) { + this.onDataAvailableFired = true; + this.isHttp2Connection = checkIsHttp2(request); + this.count += cnt; + read_stream(stream, cnt); +}; + +Http2HugeSuspendedListener.prototype.onStopRequest = function ( + request, + status +) { + Assert.ok(this.onStartRequestFired); + Assert.ok(this.onDataAvailableFired); + Assert.ok(this.isHttp2Connection); + Assert.equal(this.count, 1024 * 1024 * 1); // 1mb of data expected + request.QueryInterface(Ci.nsIProxiedChannel); + var httpProxyConnectResponseCode = request.httpProxyConnectResponseCode; + this.finish({ httpProxyConnectResponseCode }); +}; + +// Does the appropriate checks for POSTs +var Http2PostListener = function (expected_md5) { + this.expected_md5 = expected_md5; +}; + +Http2PostListener.prototype = new Http2CheckListener(); +Http2PostListener.prototype.expected_md5 = ""; + +Http2PostListener.prototype.onDataAvailable = function ( + request, + stream, + off, + cnt +) { + this.onDataAvailableFired = true; + this.isHttp2Connection = checkIsHttp2(request); + read_stream(stream, cnt); + Assert.equal( + this.expected_md5, + request.getResponseHeader("X-Calculated-MD5") + ); +}; + +var ResumeStalledChannelListener = function () {}; + +ResumeStalledChannelListener.prototype = { + onStartRequestFired: false, + onDataAvailableFired: false, + isHttp2Connection: false, + shouldBeHttp2: true, + resumable: null, + + onStartRequest: function testOnStartRequest(request) { + this.onStartRequestFired = true; + if (!Components.isSuccessCode(request.status)) { + do_throw("Channel should have a success code! (" + request.status + ")"); + } + + Assert.ok(request instanceof Ci.nsIHttpChannel); + Assert.equal(request.responseStatus, 200); + Assert.equal(request.requestSucceeded, true); + }, + + onDataAvailable: function testOnDataAvailable(request, stream, off, cnt) { + this.onDataAvailableFired = true; + this.isHttp2Connection = checkIsHttp2(request); + read_stream(stream, cnt); + }, + + onStopRequest: function testOnStopRequest(request, status) { + Assert.ok(this.onStartRequestFired); + Assert.ok(Components.isSuccessCode(status)); + Assert.ok(this.onDataAvailableFired); + Assert.ok(this.isHttp2Connection == this.shouldBeHttp2); + this.resumable.resume(); + }, +}; + +// test a large download that creates stream flow control and +// confirm we can do another independent stream while the download +// stream is stuck +async function test_http2_blocking_download(serverPort) { + var chan = makeHTTPChannel(`https://localhost:${serverPort}/bigdownload`); + var internalChannel = chan.QueryInterface(Ci.nsIHttpChannelInternal); + internalChannel.initialRwin = 500000; // make the stream.suspend push back in h2 + var p = new Promise(resolve => { + var listener = new Http2CheckListener(); + listener.finish = resolve; + listener.expected = 3 * 1024 * 1024; + chan.asyncOpen(listener); + chan.suspend(); + }); + // wait 5 seconds so that stream flow control kicks in and then see if we + // can do a basic transaction (i.e. session not blocked). afterwards resume + // channel + do_timeout(5000, function () { + var simpleChannel = makeHTTPChannel(`https://localhost:${serverPort}/`); + var sl = new ResumeStalledChannelListener(); + sl.resumable = chan; + simpleChannel.asyncOpen(sl); + }); + return p; +} + +// Make sure we make a HTTP2 connection and both us and the server mark it as such +async function test_http2_basic(serverPort) { + var chan = makeHTTPChannel(`https://localhost:${serverPort}/`); + var p = new Promise(resolve => { + var listener = new Http2CheckListener(); + listener.finish = resolve; + chan.asyncOpen(listener); + }); + return p; +} + +async function test_http2_basic_unblocked_dep(serverPort) { + var chan = makeHTTPChannel( + `https://localhost:${serverPort}/basic_unblocked_dep` + ); + var cos = chan.QueryInterface(Ci.nsIClassOfService); + cos.addClassFlags(Ci.nsIClassOfService.Unblocked); + return new Promise(resolve => { + var listener = new Http2CheckListener(); + listener.finish = resolve; + chan.asyncOpen(listener); + }); +} + +// make sure we don't use h2 when disallowed +async function test_http2_nospdy(serverPort) { + var chan = makeHTTPChannel(`https://localhost:${serverPort}/`); + return new Promise(resolve => { + var listener = new Http2CheckListener(); + listener.finish = resolve; + var internalChannel = chan.QueryInterface(Ci.nsIHttpChannelInternal); + internalChannel.allowSpdy = false; + listener.shouldBeHttp2 = false; + chan.asyncOpen(listener); + }); +} + +// Support for making sure XHR works over SPDY +function checkXhr(xhr, finish) { + if (xhr.readyState != 4) { + return; + } + + Assert.equal(xhr.status, 200); + Assert.equal(checkIsHttp2(xhr), true); + finish(); +} + +// Fires off an XHR request over h2 +async function test_http2_xhr(serverPort) { + return new Promise(resolve => { + var req = new XMLHttpRequest(); + req.open("GET", `https://localhost:${serverPort}/`, true); + req.addEventListener("readystatechange", function (evt) { + checkXhr(req, resolve); + }); + req.send(null); + }); +} + +var Http2ConcurrentListener = function () {}; + +Http2ConcurrentListener.prototype = new Http2CheckListener(); +Http2ConcurrentListener.prototype.count = 0; +Http2ConcurrentListener.prototype.target = 0; +Http2ConcurrentListener.prototype.reset = 0; +Http2ConcurrentListener.prototype.recvdHdr = 0; + +Http2ConcurrentListener.prototype.onStopRequest = function (request, status) { + this.count++; + Assert.ok(this.isHttp2Connection); + if (this.recvdHdr > 0) { + Assert.equal(request.getResponseHeader("X-Recvd"), this.recvdHdr); + } + + if (this.count == this.target) { + if (this.reset > 0) { + Services.prefs.setIntPref( + "network.http.http2.default-concurrent", + this.reset + ); + } + request.QueryInterface(Ci.nsIProxiedChannel); + var httpProxyConnectResponseCode = request.httpProxyConnectResponseCode; + this.finish({ httpProxyConnectResponseCode }); + } +}; + +async function test_http2_concurrent(concurrent_channels, serverPort) { + var p = new Promise(resolve => { + var concurrent_listener = new Http2ConcurrentListener(); + concurrent_listener.finish = resolve; + concurrent_listener.target = 201; + concurrent_listener.reset = Services.prefs.getIntPref( + "network.http.http2.default-concurrent" + ); + Services.prefs.setIntPref("network.http.http2.default-concurrent", 100); + + for (var i = 0; i < concurrent_listener.target; i++) { + concurrent_channels[i] = makeHTTPChannel( + `https://localhost:${serverPort}/750ms` + ); + concurrent_channels[i].loadFlags = Ci.nsIRequest.LOAD_BYPASS_CACHE; + concurrent_channels[i].asyncOpen(concurrent_listener); + } + }); + return p; +} + +async function test_http2_concurrent_post(concurrent_channels, serverPort) { + return new Promise(resolve => { + var concurrent_listener = new Http2ConcurrentListener(); + concurrent_listener.finish = resolve; + concurrent_listener.target = 8; + concurrent_listener.recvdHdr = posts[2].length; + concurrent_listener.reset = Services.prefs.getIntPref( + "network.http.http2.default-concurrent" + ); + Services.prefs.setIntPref("network.http.http2.default-concurrent", 3); + + for (var i = 0; i < concurrent_listener.target; i++) { + concurrent_channels[i] = makeHTTPChannel( + `https://localhost:${serverPort}/750msPost` + ); + concurrent_channels[i].loadFlags = Ci.nsIRequest.LOAD_BYPASS_CACHE; + var stream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance( + Ci.nsIStringInputStream + ); + stream.data = posts[2]; + var uchan = concurrent_channels[i].QueryInterface(Ci.nsIUploadChannel); + uchan.setUploadStream(stream, "text/plain", stream.available()); + concurrent_channels[i].requestMethod = "POST"; + concurrent_channels[i].asyncOpen(concurrent_listener); + } + }); +} + +// Test to make sure we get multiplexing right +async function test_http2_multiplex(serverPort) { + let chan1 = makeHTTPChannel(`https://localhost:${serverPort}/multiplex1`); + let chan2 = makeHTTPChannel(`https://localhost:${serverPort}/multiplex2`); + let listener1 = new Http2MultiplexListener(); + let listener2 = new Http2MultiplexListener(); + + let promises = []; + let p1 = new Promise(resolve => { + listener1.finish = resolve; + }); + promises.push(p1); + let p2 = new Promise(resolve => { + listener2.finish = resolve; + }); + promises.push(p2); + + chan1.asyncOpen(listener1); + chan2.asyncOpen(listener2); + return Promise.all(promises); +} + +// Test to make sure we gateway non-standard headers properly +async function test_http2_header(serverPort) { + let chan = makeHTTPChannel(`https://localhost:${serverPort}/header`); + let hvalue = "Headers are fun"; + chan.setRequestHeader("X-Test-Header", hvalue, false); + return new Promise(resolve => { + let listener = new Http2HeaderListener("X-Received-Test-Header", function ( + received_hvalue + ) { + Assert.equal(received_hvalue, hvalue); + }); + listener.finish = resolve; + chan.asyncOpen(listener); + }); +} + +// Test to make sure headers with invalid characters in the name are rejected +async function test_http2_invalid_response_header(serverPort, invalid_kind) { + return new Promise(resolve => { + var listener = new Http2CheckListener(); + listener.finish = resolve; + listener.shouldSucceed = false; + var chan = makeHTTPChannel( + `https://localhost:${serverPort}/invalid_response_header/${invalid_kind}` + ); + chan.asyncOpen(listener); + }); +} + +// Test to make sure cookies are split into separate fields before compression +async function test_http2_cookie_crumbling(serverPort) { + var chan = makeHTTPChannel( + `https://localhost:${serverPort}/cookie_crumbling` + ); + var cookiesSent = ["a=b", "c=d01234567890123456789", "e=f"].sort(); + chan.setRequestHeader("Cookie", cookiesSent.join("; "), false); + return new Promise(resolve => { + var listener = new Http2HeaderListener("X-Received-Header-Pairs", function ( + pairsReceived + ) { + var cookiesReceived = JSON.parse(pairsReceived) + .filter(function (pair) { + return pair[0] == "cookie"; + }) + .map(function (pair) { + return pair[1]; + }) + .sort(); + Assert.equal(cookiesReceived.length, cookiesSent.length); + cookiesReceived.forEach(function (cookieReceived, index) { + Assert.equal(cookiesSent[index], cookieReceived); + }); + }); + listener.finish = resolve; + chan.asyncOpen(listener); + }); +} + +async function test_http2_push1(loadGroup, serverPort) { + var chan = makeHTTPChannel(`https://localhost:${serverPort}/push`); + chan.loadGroup = loadGroup; + return new Promise(resolve => { + var listener = new Http2PushListener(true); + listener.finish = resolve; + listener.serverPort = serverPort; + chan.asyncOpen(listener); + }); +} + +async function test_http2_push2(loadGroup, serverPort) { + var chan = makeHTTPChannel(`https://localhost:${serverPort}/push.js`); + chan.loadGroup = loadGroup; + return new Promise(resolve => { + var listener = new Http2PushListener(true); + listener.finish = resolve; + listener.serverPort = serverPort; + chan.asyncOpen(listener); + }); +} + +async function test_http2_push3(loadGroup, serverPort) { + var chan = makeHTTPChannel(`https://localhost:${serverPort}/push2`); + chan.loadGroup = loadGroup; + return new Promise(resolve => { + var listener = new Http2PushListener(true); + listener.finish = resolve; + listener.serverPort = serverPort; + chan.asyncOpen(listener); + }); +} + +async function test_http2_push4(loadGroup, serverPort) { + var chan = makeHTTPChannel(`https://localhost:${serverPort}/push2.js`); + chan.loadGroup = loadGroup; + return new Promise(resolve => { + var listener = new Http2PushListener(true); + listener.finish = resolve; + listener.serverPort = serverPort; + chan.asyncOpen(listener); + }); +} + +async function test_http2_push5(loadGroup, serverPort) { + var chan = makeHTTPChannel(`https://localhost:${serverPort}/push5`); + chan.loadGroup = loadGroup; + return new Promise(resolve => { + var listener = new Http2PushListener(true); + listener.finish = resolve; + listener.serverPort = serverPort; + chan.asyncOpen(listener); + }); +} + +async function test_http2_push6(loadGroup, serverPort) { + var chan = makeHTTPChannel(`https://localhost:${serverPort}/push5.js`); + chan.loadGroup = loadGroup; + return new Promise(resolve => { + var listener = new Http2PushListener(true); + listener.finish = resolve; + listener.serverPort = serverPort; + chan.asyncOpen(listener); + }); +} + +// this is a basic test where the server sends a simple document with 2 header +// blocks. bug 1027364 +async function test_http2_doubleheader(serverPort) { + var chan = makeHTTPChannel(`https://localhost:${serverPort}/doubleheader`); + return new Promise(resolve => { + var listener = new Http2CheckListener(); + listener.finish = resolve; + chan.asyncOpen(listener); + }); +} + +// Make sure we handle GETs that cover more than 2 frames properly +async function test_http2_big(serverPort) { + var chan = makeHTTPChannel(`https://localhost:${serverPort}/big`); + return new Promise(resolve => { + var listener = new Http2BigListener(); + listener.finish = resolve; + chan.asyncOpen(listener); + }); +} + +async function test_http2_huge_suspended(serverPort) { + var chan = makeHTTPChannel(`https://localhost:${serverPort}/huge`); + return new Promise(resolve => { + var listener = new Http2HugeSuspendedListener(); + listener.finish = resolve; + chan.asyncOpen(listener); + chan.suspend(); + do_timeout(500, chan.resume); + }); +} + +// Support for doing a POST +function do_post(content, chan, listener, method) { + var stream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance( + Ci.nsIStringInputStream + ); + stream.data = content; + + var uchan = chan.QueryInterface(Ci.nsIUploadChannel); + uchan.setUploadStream(stream, "text/plain", stream.available()); + + chan.requestMethod = method; + + chan.asyncOpen(listener); +} + +// Make sure we can do a simple POST +async function test_http2_post(serverPort) { + var chan = makeHTTPChannel(`https://localhost:${serverPort}/post`); + var p = new Promise(resolve => { + var listener = new Http2PostListener(md5s[0]); + listener.finish = resolve; + do_post(posts[0], chan, listener, "POST"); + }); + return p; +} + +async function test_http2_empty_post(serverPort) { + var chan = makeHTTPChannel(`https://localhost:${serverPort}/post`); + var p = new Promise(resolve => { + var listener = new Http2PostListener("0"); + listener.finish = resolve; + do_post("", chan, listener, "POST"); + }); + return p; +} + +// Make sure we can do a simple PATCH +async function test_http2_patch(serverPort) { + var chan = makeHTTPChannel(`https://localhost:${serverPort}/patch`); + return new Promise(resolve => { + var listener = new Http2PostListener(md5s[0]); + listener.finish = resolve; + do_post(posts[0], chan, listener, "PATCH"); + }); +} + +// Make sure we can do a POST that covers more than 2 frames +async function test_http2_post_big(serverPort) { + var chan = makeHTTPChannel(`https://localhost:${serverPort}/post`); + return new Promise(resolve => { + var listener = new Http2PostListener(md5s[1]); + listener.finish = resolve; + do_post(posts[1], chan, listener, "POST"); + }); +} + +// When a http proxy is used alt-svc is disable. Therefore if withProxy is true, +// try numberOfTries times to connect and make sure that alt-svc is not use and we never +// connect to the HTTP/2 server. +var altsvcClientListener = function ( + finish, + httpserv, + httpserv2, + withProxy, + numberOfTries +) { + this.finish = finish; + this.httpserv = httpserv; + this.httpserv2 = httpserv2; + this.withProxy = withProxy; + this.numberOfTries = numberOfTries; +}; + +altsvcClientListener.prototype = { + onStartRequest: function test_onStartR(request) { + Assert.equal(request.status, Cr.NS_OK); + }, + + onDataAvailable: function test_ODA(request, stream, offset, cnt) { + read_stream(stream, cnt); + }, + + onStopRequest: function test_onStopR(request, status) { + var isHttp2Connection = checkIsHttp2( + request.QueryInterface(Ci.nsIHttpChannel) + ); + if (!isHttp2Connection) { + dump("/altsvc1 not over h2 yet - retry\n"); + if (this.withProxy && this.numberOfTries == 0) { + request.QueryInterface(Ci.nsIProxiedChannel); + var httpProxyConnectResponseCode = request.httpProxyConnectResponseCode; + this.finish({ httpProxyConnectResponseCode }); + return; + } + let chan = makeHTTPChannel( + `http://foo.example.com:${this.httpserv}/altsvc1`, + this.withProxy + ).QueryInterface(Ci.nsIHttpChannel); + // we use this header to tell the server to issue a altsvc frame for the + // speficied origin we will use in the next part of the test + chan.setRequestHeader( + "x-redirect-origin", + `http://foo.example.com:${this.httpserv2}`, + false + ); + chan.loadFlags = Ci.nsIRequest.LOAD_BYPASS_CACHE; + chan.asyncOpen( + new altsvcClientListener( + this.finish, + this.httpserv, + this.httpserv2, + this.withProxy, + this.numberOfTries - 1 + ) + ); + } else { + Assert.ok(isHttp2Connection); + let chan = makeHTTPChannel( + `http://foo.example.com:${this.httpserv2}/altsvc2` + ).QueryInterface(Ci.nsIHttpChannel); + chan.loadFlags = Ci.nsIRequest.LOAD_BYPASS_CACHE; + chan.asyncOpen( + new altsvcClientListener2(this.finish, this.httpserv, this.httpserv2) + ); + } + }, +}; + +var altsvcClientListener2 = function (finish, httpserv, httpserv2) { + this.finish = finish; + this.httpserv = httpserv; + this.httpserv2 = httpserv2; +}; + +altsvcClientListener2.prototype = { + onStartRequest: function test_onStartR(request) { + Assert.equal(request.status, Cr.NS_OK); + }, + + onDataAvailable: function test_ODA(request, stream, offset, cnt) { + read_stream(stream, cnt); + }, + + onStopRequest: function test_onStopR(request, status) { + var isHttp2Connection = checkIsHttp2( + request.QueryInterface(Ci.nsIHttpChannel) + ); + if (!isHttp2Connection) { + dump("/altsvc2 not over h2 yet - retry\n"); + var chan = makeHTTPChannel( + `http://foo.example.com:${this.httpserv2}/altsvc2` + ).QueryInterface(Ci.nsIHttpChannel); + chan.loadFlags = Ci.nsIRequest.LOAD_BYPASS_CACHE; + chan.asyncOpen( + new altsvcClientListener2(this.finish, this.httpserv, this.httpserv2) + ); + } else { + Assert.ok(isHttp2Connection); + request.QueryInterface(Ci.nsIProxiedChannel); + var httpProxyConnectResponseCode = request.httpProxyConnectResponseCode; + this.finish({ httpProxyConnectResponseCode }); + } + }, +}; + +async function test_http2_altsvc(httpserv, httpserv2, withProxy) { + var chan = makeHTTPChannel( + `http://foo.example.com:${httpserv}/altsvc1`, + withProxy + ).QueryInterface(Ci.nsIHttpChannel); + return new Promise(resolve => { + var numberOfTries = 0; + if (withProxy) { + numberOfTries = 20; + } + chan.asyncOpen( + new altsvcClientListener( + resolve, + httpserv, + httpserv2, + withProxy, + numberOfTries + ) + ); + }); +} + +var Http2PushApiListener = function (finish, serverPort) { + this.finish = finish; + this.serverPort = serverPort; +}; + +Http2PushApiListener.prototype = { + checksPending: 9, // 4 onDataAvailable and 5 onStop + + getInterface(aIID) { + return this.QueryInterface(aIID); + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsIHttpPushListener", + "nsIStreamListener", + ]), + + // nsIHttpPushListener + onPush: function onPush(associatedChannel, pushChannel) { + Assert.equal( + associatedChannel.originalURI.spec, + "https://localhost:" + this.serverPort + "/pushapi1" + ); + Assert.equal(pushChannel.getRequestHeader("x-pushed-request"), "true"); + + pushChannel.asyncOpen(this); + if ( + pushChannel.originalURI.spec == + "https://localhost:" + this.serverPort + "/pushapi1/2" + ) { + pushChannel.cancel(Cr.NS_ERROR_ABORT); + } else if ( + pushChannel.originalURI.spec == + "https://localhost:" + this.serverPort + "/pushapi1/3" + ) { + Assert.ok(pushChannel.getRequestHeader("Accept-Encoding").includes("br")); + } + }, + + // normal Channel listeners + onStartRequest: function pushAPIOnStart(request) {}, + + onDataAvailable: function pushAPIOnDataAvailable( + request, + stream, + offset, + cnt + ) { + Assert.notEqual( + request.originalURI.spec, + `https://localhost:${this.serverPort}/pushapi1/2` + ); + + var data = read_stream(stream, cnt); + + if ( + request.originalURI.spec == + `https://localhost:${this.serverPort}/pushapi1` + ) { + Assert.equal(data[0], "0"); + --this.checksPending; + } else if ( + request.originalURI.spec == + `https://localhost:${this.serverPort}/pushapi1/1` + ) { + Assert.equal(data[0], "1"); + --this.checksPending; // twice + } else if ( + request.originalURI.spec == + `https://localhost:${this.serverPort}/pushapi1/3` + ) { + Assert.equal(data[0], "3"); + --this.checksPending; + } else { + Assert.equal(true, false); + } + }, + + onStopRequest: function test_onStopR(request, status) { + if ( + request.originalURI.spec == + `https://localhost:${this.serverPort}/pushapi1/2` + ) { + Assert.equal(request.status, Cr.NS_ERROR_ABORT); + } else { + Assert.equal(request.status, Cr.NS_OK); + } + + --this.checksPending; // 5 times - one for each push plus the pull + if (!this.checksPending) { + request.QueryInterface(Ci.nsIProxiedChannel); + var httpProxyConnectResponseCode = request.httpProxyConnectResponseCode; + this.finish({ httpProxyConnectResponseCode }); + } + }, +}; + +// pushAPI testcase 1 expects +// 1 to pull /pushapi1 with 0 +// 2 to see /pushapi1/1 with 1 +// 3 to see /pushapi1/1 with 1 (again) +// 4 to see /pushapi1/2 that it will cancel +// 5 to see /pushapi1/3 with 3 with brotli + +async function test_http2_pushapi_1(loadGroup, serverPort) { + var chan = makeHTTPChannel(`https://localhost:${serverPort}/pushapi1`); + chan.loadGroup = loadGroup; + return new Promise(resolve => { + var listener = new Http2PushApiListener(resolve, serverPort); + chan.notificationCallbacks = listener; + chan.asyncOpen(listener); + }); +} + +var WrongSuiteListener = function () {}; + +WrongSuiteListener.prototype = new Http2CheckListener(); +WrongSuiteListener.prototype.shouldBeHttp2 = false; +WrongSuiteListener.prototype.onStopRequest = function (request, status) { + Services.prefs.setBoolPref( + "security.ssl3.ecdhe_rsa_aes_128_gcm_sha256", + true + ); + Services.prefs.clearUserPref("security.tls.version.max"); + Http2CheckListener.prototype.onStopRequest.call(this, request, status); +}; + +// test that we use h1 without the mandatory cipher suite available when +// offering at most tls1.2 +async function test_http2_wrongsuite_tls12(serverPort) { + Services.prefs.setBoolPref( + "security.ssl3.ecdhe_rsa_aes_128_gcm_sha256", + false + ); + Services.prefs.setIntPref("security.tls.version.max", 3); + var chan = makeHTTPChannel(`https://localhost:${serverPort}/wrongsuite`); + chan.loadFlags = + Ci.nsIRequest.LOAD_FRESH_CONNECTION | + Ci.nsIChannel.LOAD_INITIAL_DOCUMENT_URI; + return new Promise(resolve => { + var listener = new WrongSuiteListener(); + listener.finish = resolve; + chan.asyncOpen(listener); + }); +} + +// test that we use h2 when offering tls1.3 or higher regardless of if the +// mandatory cipher suite is available +async function test_http2_wrongsuite_tls13(serverPort) { + Services.prefs.setBoolPref( + "security.ssl3.ecdhe_rsa_aes_128_gcm_sha256", + false + ); + var chan = makeHTTPChannel(`https://localhost:${serverPort}/wrongsuite`); + chan.loadFlags = + Ci.nsIRequest.LOAD_FRESH_CONNECTION | + Ci.nsIChannel.LOAD_INITIAL_DOCUMENT_URI; + return new Promise(resolve => { + var listener = new WrongSuiteListener(); + listener.finish = resolve; + listener.shouldBeHttp2 = true; + chan.asyncOpen(listener); + }); +} + +async function test_http2_h11required_stream(serverPort) { + var chan = makeHTTPChannel( + `https://localhost:${serverPort}/h11required_stream` + ); + return new Promise(resolve => { + var listener = new Http2CheckListener(); + listener.finish = resolve; + listener.shouldBeHttp2 = false; + chan.asyncOpen(listener); + }); +} + +function H11RequiredSessionListener() {} + +H11RequiredSessionListener.prototype = new Http2CheckListener(); + +H11RequiredSessionListener.prototype.onStopRequest = function ( + request, + status +) { + var streamReused = request.getResponseHeader("X-H11Required-Stream-Ok"); + Assert.equal(streamReused, "yes"); + + Assert.ok(this.onStartRequestFired); + Assert.ok(this.onDataAvailableFired); + Assert.ok(this.isHttp2Connection == this.shouldBeHttp2); + + request.QueryInterface(Ci.nsIProxiedChannel); + var httpProxyConnectResponseCode = request.httpProxyConnectResponseCode; + this.finish({ httpProxyConnectResponseCode }); +}; + +async function test_http2_h11required_session(serverPort) { + var chan = makeHTTPChannel( + `https://localhost:${serverPort}/h11required_session` + ); + return new Promise(resolve => { + var listener = new H11RequiredSessionListener(); + listener.finish = resolve; + listener.shouldBeHttp2 = false; + chan.asyncOpen(listener); + }); +} + +async function test_http2_retry_rst(serverPort) { + var chan = makeHTTPChannel(`https://localhost:${serverPort}/rstonce`); + return new Promise(resolve => { + var listener = new Http2CheckListener(); + listener.finish = resolve; + chan.asyncOpen(listener); + }); +} + +async function test_http2_continuations(loadGroup, serverPort) { + var chan = makeHTTPChannel( + `https://localhost:${serverPort}/continuedheaders` + ); + chan.loadGroup = loadGroup; + return new Promise(resolve => { + var listener = new Http2ContinuedHeaderListener(); + listener.finish = resolve; + listener.serverPort = serverPort; + chan.notificationCallbacks = listener; + chan.asyncOpen(listener); + }); +} + +function Http2IllegalHpackValidationListener() {} + +Http2IllegalHpackValidationListener.prototype = new Http2CheckListener(); +Http2IllegalHpackValidationListener.prototype.shouldGoAway = false; + +Http2IllegalHpackValidationListener.prototype.onStopRequest = function ( + request, + status +) { + var wentAway = request.getResponseHeader("X-Did-Goaway") === "yes"; + Assert.equal(wentAway, this.shouldGoAway); + + Assert.ok(this.onStartRequestFired); + Assert.ok(this.onDataAvailableFired); + Assert.ok(this.isHttp2Connection == this.shouldBeHttp2); + + request.QueryInterface(Ci.nsIProxiedChannel); + var httpProxyConnectResponseCode = request.httpProxyConnectResponseCode; + this.finish({ httpProxyConnectResponseCode }); +}; + +function Http2IllegalHpackListener() {} +Http2IllegalHpackListener.prototype = new Http2CheckListener(); +Http2IllegalHpackListener.prototype.shouldGoAway = false; + +Http2IllegalHpackListener.prototype.onStopRequest = function (request, status) { + var chan = makeHTTPChannel( + `https://localhost:${this.serverPort}/illegalhpack_validate` + ); + var listener = new Http2IllegalHpackValidationListener(); + listener.finish = this.finish; + listener.shouldGoAway = this.shouldGoAway; + chan.asyncOpen(listener); +}; + +async function test_http2_illegalhpacksoft(serverPort) { + var chan = makeHTTPChannel( + `https://localhost:${serverPort}/illegalhpacksoft` + ); + return new Promise(resolve => { + var listener = new Http2IllegalHpackListener(); + listener.finish = resolve; + listener.serverPort = serverPort; + listener.shouldGoAway = false; + listener.shouldSucceed = false; + chan.asyncOpen(listener); + }); +} + +async function test_http2_illegalhpackhard(serverPort) { + var chan = makeHTTPChannel( + `https://localhost:${serverPort}/illegalhpackhard` + ); + return new Promise(resolve => { + var listener = new Http2IllegalHpackListener(); + listener.finish = resolve; + listener.serverPort = serverPort; + listener.shouldGoAway = true; + listener.shouldSucceed = false; + chan.asyncOpen(listener); + }); +} + +async function test_http2_folded_header(loadGroup, serverPort) { + var chan = makeHTTPChannel(`https://localhost:${serverPort}/foldedheader`); + chan.loadGroup = loadGroup; + return new Promise(resolve => { + var listener = new Http2CheckListener(); + listener.finish = resolve; + listener.shouldSucceed = false; + chan.asyncOpen(listener); + }); +} + +async function test_http2_empty_data(serverPort) { + var chan = makeHTTPChannel(`https://localhost:${serverPort}/emptydata`); + return new Promise(resolve => { + var listener = new Http2CheckListener(); + listener.finish = resolve; + chan.asyncOpen(listener); + }); +} + +async function test_http2_push_firstparty1(loadGroup, serverPort) { + var chan = makeHTTPChannel(`https://localhost:${serverPort}/push`); + chan.loadGroup = loadGroup; + chan.loadInfo.originAttributes = { firstPartyDomain: "foo.com" }; + return new Promise(resolve => { + var listener = new Http2PushListener(true); + listener.finish = resolve; + listener.serverPort = serverPort; + chan.asyncOpen(listener); + }); +} + +async function test_http2_push_firstparty2(loadGroup, serverPort) { + var chan = makeHTTPChannel(`https://localhost:${serverPort}/push.js`); + chan.loadGroup = loadGroup; + chan.loadInfo.originAttributes = { firstPartyDomain: "bar.com" }; + return new Promise(resolve => { + var listener = new Http2PushListener(false); + listener.finish = resolve; + listener.serverPort = serverPort; + chan.asyncOpen(listener); + }); +} + +async function test_http2_push_firstparty3(loadGroup, serverPort) { + var chan = makeHTTPChannel(`https://localhost:${serverPort}/push.js`); + chan.loadGroup = loadGroup; + chan.loadInfo.originAttributes = { firstPartyDomain: "foo.com" }; + return new Promise(resolve => { + var listener = new Http2PushListener(true); + listener.finish = resolve; + listener.serverPort = serverPort; + chan.asyncOpen(listener); + }); +} + +async function test_http2_push_userContext1(loadGroup, serverPort) { + var chan = makeHTTPChannel(`https://localhost:${serverPort}/push`); + chan.loadGroup = loadGroup; + chan.loadInfo.originAttributes = { userContextId: 1 }; + return new Promise(resolve => { + var listener = new Http2PushListener(true); + listener.finish = resolve; + listener.serverPort = serverPort; + chan.asyncOpen(listener); + }); +} + +async function test_http2_push_userContext2(loadGroup, serverPort) { + var chan = makeHTTPChannel(`https://localhost:${serverPort}/push.js`); + chan.loadGroup = loadGroup; + chan.loadInfo.originAttributes = { userContextId: 2 }; + return new Promise(resolve => { + var listener = new Http2PushListener(false); + listener.finish = resolve; + listener.serverPort = serverPort; + chan.asyncOpen(listener); + }); +} + +async function test_http2_push_userContext3(loadGroup, serverPort) { + var chan = makeHTTPChannel(`https://localhost:${serverPort}/push.js`); + chan.loadGroup = loadGroup; + chan.loadInfo.originAttributes = { userContextId: 1 }; + return new Promise(resolve => { + var listener = new Http2PushListener(true); + listener.finish = resolve; + listener.serverPort = serverPort; + chan.asyncOpen(listener); + }); +} + +async function test_http2_status_phrase(serverPort) { + var chan = makeHTTPChannel(`https://localhost:${serverPort}/statusphrase`); + return new Promise(resolve => { + var listener = new Http2CheckListener(); + listener.finish = resolve; + listener.shouldSucceed = false; + chan.asyncOpen(listener); + }); +} + +var PulledDiskCacheListener = function () {}; +PulledDiskCacheListener.prototype = new Http2CheckListener(); +PulledDiskCacheListener.prototype.EXPECTED_DATA = "this was pulled via h2"; +PulledDiskCacheListener.prototype.readData = ""; +PulledDiskCacheListener.prototype.onDataAvailable = + function testOnDataAvailable(request, stream, off, cnt) { + this.onDataAvailableFired = true; + this.isHttp2Connection = checkIsHttp2(request); + this.accum += cnt; + this.readData += read_stream(stream, cnt); + }; +PulledDiskCacheListener.prototype.onStopRequest = function testOnStopRequest( + request, + status +) { + Assert.equal(this.EXPECTED_DATA, this.readData); + Http2CheckListener.prorotype.onStopRequest.call(this, request, status); +}; + +const DISK_CACHE_DATA = "this is from disk cache"; + +var FromDiskCacheListener = function (finish, loadGroup, serverPort) { + this.finish = finish; + this.loadGroup = loadGroup; + this.serverPort = serverPort; +}; +FromDiskCacheListener.prototype = { + onStartRequestFired: false, + onDataAvailableFired: false, + readData: "", + + onStartRequest: function testOnStartRequest(request) { + this.onStartRequestFired = true; + if (!Components.isSuccessCode(request.status)) { + do_throw("Channel should have a success code! (" + request.status + ")"); + } + + Assert.ok(request instanceof Ci.nsIHttpChannel); + Assert.ok(request.requestSucceeded); + Assert.equal(request.responseStatus, 200); + }, + + onDataAvailable: function testOnDataAvailable(request, stream, off, cnt) { + this.onDataAvailableFired = true; + this.readData += read_stream(stream, cnt); + }, + + onStopRequest: function testOnStopRequest(request, status) { + Assert.ok(this.onStartRequestFired); + Assert.ok(Components.isSuccessCode(status)); + Assert.ok(this.onDataAvailableFired); + Assert.equal(this.readData, DISK_CACHE_DATA); + + evict_cache_entries("disk"); + syncWithCacheIOThread(() => { + // Now that we know the entry is out of the disk cache, check to make sure + // we don't have this hiding in the push cache somewhere - if we do, it + // didn't get cancelled, and we have a bug. + var chan = makeHTTPChannel( + `https://localhost:${this.serverPort}/diskcache` + ); + var listener = new PulledDiskCacheListener(); + listener.finish = this.finish; + chan.loadGroup = this.loadGroup; + chan.asyncOpen(listener); + }); + }, +}; + +var Http2DiskCachePushListener = function () {}; +Http2DiskCachePushListener.prototype = new Http2CheckListener(); + +Http2DiskCachePushListener.onStopRequest = function (request, status) { + Assert.ok(this.onStartRequestFired); + Assert.ok(Components.isSuccessCode(status)); + Assert.ok(this.onDataAvailableFired); + Assert.ok(this.isHttp2Connection == this.shouldBeHttp2); + + // Now we need to open a channel to ensure we get data from the disk cache + // for the pushed item, instead of from the push cache. + var chan = makeHTTPChannel(`https://localhost:${this.serverPort}/diskcache`); + var listener = new FromDiskCacheListener( + this.finish, + this.loadGroup, + this.serverPort + ); + chan.loadGroup = this.loadGroup; + chan.asyncOpen(listener); +}; + +function continue_test_http2_disk_cache_push( + status, + entry, + finish, + loadGroup, + serverPort +) { + // TODO - store stuff in cache entry, then open an h2 channel that will push + // this, once that completes, open a channel for the cache entry we made and + // ensure it came from disk cache, not the push cache. + var outputStream = entry.openOutputStream(0, -1); + outputStream.write(DISK_CACHE_DATA, DISK_CACHE_DATA.length); + + // Now we open our URL that will push data for the URL above + var chan = makeHTTPChannel(`https://localhost:${serverPort}/pushindisk`); + var listener = new Http2DiskCachePushListener(); + listener.finish = finish; + listener.loadGroup = loadGroup; + listener.serverPort = serverPort; + chan.loadGroup = loadGroup; + chan.asyncOpen(listener); +} + +async function test_http2_disk_cache_push(loadGroup, serverPort) { + return new Promise(resolve => { + asyncOpenCacheEntry( + `https://localhost:${serverPort}/diskcache`, + "disk", + Ci.nsICacheStorage.OPEN_NORMALLY, + null, + function (status, entry) { + continue_test_http2_disk_cache_push( + status, + entry, + resolve, + loadGroup, + serverPort + ); + }, + false + ); + }); +} + +var Http2DoublepushListener = function () {}; +Http2DoublepushListener.prototype = new Http2CheckListener(); +Http2DoublepushListener.prototype.onStopRequest = function (request, status) { + Assert.ok(this.onStartRequestFired); + Assert.ok(Components.isSuccessCode(status)); + Assert.ok(this.onDataAvailableFired); + Assert.ok(this.isHttp2Connection == this.shouldBeHttp2); + + var chan = makeHTTPChannel( + `https://localhost:${this.serverPort}/doublypushed` + ); + var listener = new Http2DoublypushedListener(); + listener.finish = this.finish; + chan.loadGroup = this.loadGroup; + chan.asyncOpen(listener); +}; + +var Http2DoublypushedListener = function () {}; +Http2DoublypushedListener.prototype = new Http2CheckListener(); +Http2DoublypushedListener.prototype.readData = ""; +Http2DoublypushedListener.prototype.onDataAvailable = function ( + request, + stream, + off, + cnt +) { + this.onDataAvailableFired = true; + this.accum += cnt; + this.readData += read_stream(stream, cnt); +}; +Http2DoublypushedListener.prototype.onStopRequest = function (request, status) { + Assert.ok(this.onStartRequestFired); + Assert.ok(Components.isSuccessCode(status)); + Assert.ok(this.onDataAvailableFired); + Assert.equal(this.readData, "pushed"); + + request.QueryInterface(Ci.nsIProxiedChannel); + let httpProxyConnectResponseCode = request.httpProxyConnectResponseCode; + this.finish({ httpProxyConnectResponseCode }); +}; + +function test_http2_doublepush(loadGroup, serverPort) { + var chan = makeHTTPChannel(`https://localhost:${serverPort}/doublepush`); + return new Promise(resolve => { + var listener = new Http2DoublepushListener(); + listener.finish = resolve; + listener.loadGroup = loadGroup; + listener.serverPort = serverPort; + chan.loadGroup = loadGroup; + chan.asyncOpen(listener); + }); +} -- cgit v1.2.3