/* 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"; /* import-globals-from head_cache.js */ /* import-globals-from head_cookies.js */ /* import-globals-from head_channels.js */ /* import-globals-from head_http3.js */ const { HttpServer } = ChromeUtils.importESModule( "resource://testing-common/httpd.sys.mjs" ); function makeChan(uri) { let chan = NetUtil.newChannel({ uri, loadUsingSystemPrincipal: true, }).QueryInterface(Ci.nsIHttpChannel); chan.loadFlags = Ci.nsIChannel.LOAD_INITIAL_DOCUMENT_URI; return chan; } // Promise-backed Http3CheckListener class Http3CheckListener { constructor( { expectedStatus = Cr.NS_OK, expectedRoute = "" } = {}, resolve, reject ) { this.onDataAvailableFired = false; this.expectedStatus = expectedStatus; this.expectedRoute = expectedRoute; this._resolve = resolve; this._reject = reject; } onStartRequest(request) { Assert.ok(request instanceof Ci.nsIHttpChannel); Assert.equal(request.status, this.expectedStatus); if (Components.isSuccessCode(this.expectedStatus)) { Assert.equal(request.responseStatus, 200); } } onDataAvailable(request, stream, off, cnt) { this.onDataAvailableFired = true; read_stream(stream, cnt); } onStopRequest(request, status) { Assert.equal(status, this.expectedStatus); let routed = "NA"; try { routed = request.getRequestHeader("Alt-Used"); } catch (e) {} dump("routed is " + routed + "\n"); Assert.equal(routed, this.expectedRoute); if (Components.isSuccessCode(this.expectedStatus)) { let httpVersion = ""; try { httpVersion = request.protocolVersion; } catch (e) {} Assert.equal(httpVersion, "h3"); Assert.equal(this.onDataAvailableFired, true); Assert.equal(request.getResponseHeader("X-Firefox-Http3"), "h3"); } this._resolve?.(request); } } class WaitForHttp3Listener extends Http3CheckListener { constructor( { expectedStatus = Cr.NS_OK, expectedRoute = "", uri = "", h3AltSvc = "", retry, delayMs = 500, } = {}, resolve, reject ) { super({ expectedStatus, expectedRoute }, resolve, reject); this.uri = uri; this.h3AltSvc = h3AltSvc; this._retry = retry; // function to re-open the request this._delayMs = delayMs; // poll interval } onStopRequest(request, status) { Assert.equal(status, this.expectedStatus); let routed = "NA"; try { routed = request.getRequestHeader("Alt-Used"); } catch (e) {} dump(`routed is ${routed}\n`); let httpVersion = ""; try { httpVersion = request.protocolVersion; } catch (e) {} if (routed === this.expectedRoute) { // (This is where run_next_test() used to be.) Assert.equal(routed, this.expectedRoute); // useful log/assert Assert.equal(httpVersion, "h3"); this._resolve?.(request); return; } // Not routed yet: mirror old behavior (log + supportsHTTP3 check + retry) dump("poll later for alt-svc mapping\n"); if (httpVersion === "h2") { request.QueryInterface(Ci.nsIHttpChannelInternal); Assert.ok(request.supportsHTTP3); } if (typeof this._retry === "function") { // schedule another attempt (replaces do_test_pending/do_timeout recursion) do_timeout(this._delayMs, () => this._retry(this.uri, this.expectedRoute, this.h3AltSvc) ); } // Promise remains pending until a later attempt matches expectedRoute. } } // Factory to create { listener, promise } function createHttp3CheckListener(options = {}) { let resolve, reject; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); const listener = new Http3CheckListener(options, resolve, reject); return { listener, promise }; } function createWaitForHttp3Listener(options = {}) { let resolve, reject; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); const listener = new WaitForHttp3Listener(options, resolve, reject); return { listener, promise }; } // --- Async wrapper that does the polling by re-issuing the request --- async function waitForHttp3Route( uri, expectedRoute, altSvc, { delayMs = 500 } = {} ) { let listenerRef; // Function to (re)open the channel using the same listener instance. const retry = () => { const chan = makeChan(uri); if (altSvc) { chan.setRequestHeader("x-altsvc", altSvc, false); } chan.asyncOpen(listenerRef); }; const { listener, promise } = createWaitForHttp3Listener({ expectedStatus: Cr.NS_OK, expectedRoute, uri, h3AltSvc: altSvc, retry, delayMs, }); listenerRef = listener; // Kick off first attempt; subsequent attempts are scheduled by the listener. retry(); // Resolves only when routed === expectedRoute return promise; } // Promise-backed MultipleListener class MultipleListener { constructor( { number_of_parallel_requests = 0, expectedRoute = "", with_error = Cr.NS_OK, // NS_OK means we expect success for all } = {}, resolve, reject ) { this.number_of_parallel_requests = number_of_parallel_requests; this.expectedRoute = expectedRoute; this.with_error = with_error; this.count_of_done_requests = 0; this.error_found_onstart = false; this.error_found_onstop = false; this.need_cancel_found = false; this._resolve = resolve; this._reject = reject; } onStartRequest(request) { Assert.ok(request instanceof Ci.nsIHttpChannel); // Optional cancel behavior via header "CancelMe" let need_cancel = ""; try { need_cancel = request.getRequestHeader("CancelMe"); } catch (_) {} if (need_cancel !== "") { this.need_cancel_found = true; request.cancel(Cr.NS_ERROR_ABORT); return; } // Original logic: either 200 OK for success, or exactly one failure if (Components.isSuccessCode(request.status)) { Assert.equal(request.responseStatus, 200); } else if (this.error_found_onstart) { // Fail fast: more than one failing request on start this._reject?.( new Error("We should have only one request failing (onStart).") ); } else { Assert.equal(request.status, this.with_error); this.error_found_onstart = true; } } onDataAvailable(request, stream, off, cnt) { read_stream(stream, cnt); } onStopRequest(request) { // Check Alt-Used routing matches expectation let routed = ""; try { routed = request.getRequestHeader("Alt-Used"); } catch (_) {} Assert.equal(routed, this.expectedRoute); // If success, ensure HTTP/3 if (Components.isSuccessCode(request.status)) { let httpVersion = ""; try { httpVersion = request.protocolVersion; } catch (_) {} Assert.equal(httpVersion, "h3"); } // Track/validate failures (at most one) if (!Components.isSuccessCode(request.status)) { if (this.error_found_onstop) { this._reject?.( new Error("We should have only one request failing (onStop).") ); return; } Assert.equal(request.status, this.with_error); this.error_found_onstop = true; } // Count completion and maybe resolve this.count_of_done_requests++; if (this.count_of_done_requests === this.number_of_parallel_requests) { if (Components.isSuccessCode(this.with_error)) { // All were expected to succeed Assert.equal(this.error_found_onstart, false); Assert.equal(this.error_found_onstop, false); } else { // One failure was expected OR a cancel path was exercised Assert.ok(this.error_found_onstart || this.need_cancel_found); Assert.equal(this.error_found_onstop, true); } this._resolve?.(); } } } // Factory to create { listener, promise } function createMultipleListener(options = {}) { let resolve, reject; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); const listener = new MultipleListener(options, resolve, reject); return { listener, promise }; } async function do_test_multiple_requests( number_of_parallel_requests, h3Route, httpsOrigin ) { dump("test_multiple_requests()\n"); const { listener, promise } = createMultipleListener({ number_of_parallel_requests, expectedRoute: h3Route, with_error: Cr.NS_OK, }); for (let i = 0; i < number_of_parallel_requests; i++) { const chan = makeChan(httpsOrigin + "20000"); chan.asyncOpen(listener); } await promise; } async function do_test_request_cancelled_by_server(h3Route, httpsOrigin) { dump("do_test_request_cancelled_by_server()\n"); const { listener, promise } = createHttp3CheckListener({ expectedStatus: Cr.NS_ERROR_NET_INTERRUPT, expectedRoute: h3Route, }); const chan = makeChan(httpsOrigin + "RequestCancelled"); chan.asyncOpen(listener); // Resolves at the point where run_next_test() used to be called await promise; } // Promise-backed Http3CheckListener must already exist: // createHttp3CheckListener({ expectedStatus, expectedRoute }) class CancelRequestListener extends Http3CheckListener { constructor({ expectedRoute = "" } = {}, resolve, reject) { super( { expectedStatus: Cr.NS_ERROR_ABORT, expectedRoute }, resolve, reject ); } onStartRequest(request) { Assert.ok(request instanceof Ci.nsIHttpChannel); Assert.equal(Components.isSuccessCode(request.status), true); // Cancel the request immediately (simulate Necko cancelling) request.cancel(Cr.NS_ERROR_ABORT); } } function createCancelRequestListener(options = {}) { let resolve, reject; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); const listener = new CancelRequestListener(options, resolve, reject); return { listener, promise }; } // Cancel stream after OnStartRequest. async function do_test_stream_cancelled_by_necko(h3Route, httpsOrigin) { dump("do_test_stream_cancelled_by_necko()\n"); const { listener, promise } = createCancelRequestListener({ expectedRoute: h3Route, }); const chan = makeChan(httpsOrigin + "20000"); chan.asyncOpen(listener); // Resolves at the end of onStopRequest (where run_next_test() used to be) await promise; } async function do_test_multiple_request_one_is_cancelled( number_of_parallel_requests, h3Route, httpsOrigin ) { dump("do_test_multiple_request_one_is_cancelled()\n"); const { listener, promise } = createMultipleListener({ number_of_parallel_requests, expectedRoute: h3Route, with_error: Cr.NS_ERROR_NET_INTERRUPT, // one request is expected to fail (server-cancelled) }); for (let i = 0; i < number_of_parallel_requests; i++) { let uri = httpsOrigin + "20000"; if (i === 4) { // Add a request that will be cancelled by the server. uri = httpsOrigin + "RequestCancelled"; } const chan = makeChan(uri); chan.asyncOpen(listener); } // Resolves when all parallel requests complete and invariants are checked await promise; } async function do_test_multiple_request_one_is_cancelled_by_necko( number_of_parallel_requests, h3Route, httpsOrigin ) { dump("do_test_multiple_request_one_is_cancelled_by_necko()\n"); const { listener, promise } = createMultipleListener({ number_of_parallel_requests, expectedRoute: h3Route, with_error: Cr.NS_ERROR_ABORT, }); for (let i = 0; i < number_of_parallel_requests; i++) { let chan = makeChan(httpsOrigin + "20000"); if (i === 4) { // MultipleListener will cancel request with this header. chan.setRequestHeader("CancelMe", "true", false); } chan.asyncOpen(listener); } // Resolves when all parallel requests complete and invariants are checked await promise; } // Promise-backed Http3CheckListener assumed available: // function createHttp3CheckListener({ expectedStatus, expectedRoute }) class PostListener extends Http3CheckListener { constructor(opts = {}, resolve, reject) { super(opts, resolve, reject); } onDataAvailable(request, stream, off, cnt) { this.onDataAvailableFired = true; read_stream(stream, cnt); } } // Factory for PostListener function createPostListener(options = {}) { let resolve, reject; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); const listener = new PostListener(options, resolve, reject); return { listener, promise }; } // Helper to perform a POST (or any method with a body) function openWithBody( content, chan, method = "POST", contentType = "text/plain" ) { const stream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance( Ci.nsIStringInputStream ); stream.setByteStringData(content); const uchan = chan.QueryInterface(Ci.nsIUploadChannel); uchan.setUploadStream(stream, contentType, stream.available()); chan.requestMethod = method; return chan; } // Generate a post with known pre-calculated md5 sum. function generateContent(size) { let content = ""; for (let i = 0; i < size; i++) { content += "0"; } return content; } let post = generateContent(10); // Test a simple POST (async) async function do_test_post(httpsOrigin, h3Route) { dump("do_test_post()\n"); const chan = makeChan(httpsOrigin + "post"); openWithBody(post, chan, "POST"); const { listener, promise } = createPostListener({ expectedStatus: Cr.NS_OK, expectedRoute: h3Route, }); chan.asyncOpen(listener); await promise; // resolves at end of onStopRequest in Http3CheckListener } // Test a simple PATCH async function do_test_patch(httpsOrigin, h3Route) { dump("do_test_post()\n"); const chan = makeChan(httpsOrigin + "patch"); openWithBody(post, chan, "PATCH"); const { listener, promise } = createPostListener({ expectedStatus: Cr.NS_OK, expectedRoute: h3Route, }); chan.asyncOpen(listener); await promise; } let h1Server = null; let altsvcHost = ""; let httpOrigin = ""; function h1Response(metadata, response) { response.setStatusLine(metadata.httpVersion, 200, "OK"); response.setHeader("Content-Type", "text/plain", false); response.setHeader("Connection", "close", false); response.setHeader("Cache-Control", "no-cache", false); response.setHeader("Access-Control-Allow-Origin", "*", false); response.setHeader("Access-Control-Allow-Method", "GET", false); response.setHeader("Access-Control-Allow-Headers", "x-altsvc", false); try { let hval = "h3=" + metadata.getHeader("x-altsvc"); response.setHeader("Alt-Svc", hval, false); } catch (e) {} let body = "Q: What did 0 say to 8? A: Nice Belt!\n"; response.bodyOutputStream.write(body, body.length); } function h1ServerWK(metadata, response) { response.setStatusLine(metadata.httpVersion, 200, "OK"); response.setHeader("Content-Type", "application/json", false); response.setHeader("Connection", "close", false); response.setHeader("Cache-Control", "no-cache", false); response.setHeader("Access-Control-Allow-Origin", "*", false); response.setHeader("Access-Control-Allow-Method", "GET", false); response.setHeader("Access-Control-Allow-Headers", "x-altsvc", false); let body = `["http://${altsvcHost}:${h1Server.identity.primaryPort}"]`; response.bodyOutputStream.write(body, body.length); } function setup_h1_server(host) { altsvcHost = host; h1Server = new HttpServer(); h1Server.registerPathHandler("/http3-test", h1Response); h1Server.registerPathHandler("/.well-known/http-opportunistic", h1ServerWK); h1Server.registerPathHandler("/VersionFallback", h1Response); h1Server.start(-1); h1Server.identity.setPrimary( "http", altsvcHost, h1Server.identity.primaryPort ); httpOrigin = `http://${altsvcHost}:${h1Server.identity.primaryPort}/`; registerCleanupFunction(() => { h1Server.stop(); }); } // Promise-backed base assumed available: // class Http3CheckListener { ... } // function createHttp3CheckListener(opts) { return { listener, promise }; } class SlowReceiverListener extends Http3CheckListener { constructor( { expectedStatus = Cr.NS_OK, expectedRoute = "", expectedBytes = 10_000_000, } = {}, resolve, reject ) { super({ expectedStatus, expectedRoute }, resolve, reject); this.count = 0; this.expectedBytes = expectedBytes; } onDataAvailable(request, stream, off, cnt) { this.onDataAvailableFired = true; this.count += cnt; read_stream(stream, cnt); } onStopRequest(request, status) { Assert.equal(status, this.expectedStatus); Assert.equal(this.count, this.expectedBytes); let routed = "NA"; try { routed = request.getRequestHeader("Alt-Used"); } catch (e) {} dump(`routed is ${routed}\n`); Assert.equal(routed, this.expectedRoute); if (Components.isSuccessCode(this.expectedStatus)) { let httpVersion = ""; try { httpVersion = request.protocolVersion; } catch (e) {} Assert.equal(httpVersion, "h3"); Assert.equal(this.onDataAvailableFired, true); } // Resolve where run_next_test() used to be this._resolve?.(request); } } function createSlowReceiverListener(options = {}) { let resolve, reject; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); const listener = new SlowReceiverListener(options, resolve, reject); return { listener, promise }; } // Test: slow receiver (suspend, then resume) async function do_test_slow_receiver(httpsOrigin, h3Route) { dump("do_test_slow_receiver()\n"); const chan = makeChan(httpsOrigin + "10000000"); const { listener, promise } = createSlowReceiverListener({ expectedStatus: Cr.NS_OK, expectedRoute: h3Route, expectedBytes: 10_000_000, }); chan.asyncOpen(listener); // Suspend immediately, then resume after 1s (replaces do_test_pending/do_timeout) chan.suspend(); await new Promise(r => do_timeout(1000, r)); chan.resume(); // Wait for completion (used to be run_next_test/do_test_finished) await promise; } // Promise-backed listener for version fallback checks class CheckFallbackListener { constructor(resolve, reject) { this._resolve = resolve; this._reject = reject; } onStartRequest(request) { Assert.ok(request instanceof Ci.nsIHttpChannel); Assert.equal(request.status, Cr.NS_OK); Assert.equal(request.responseStatus, 200); } onDataAvailable(request, stream, off, cnt) { read_stream(stream, cnt); } onStopRequest(request, status) { Assert.equal(status, Cr.NS_OK); let routed = "NA"; try { routed = request.getRequestHeader("Alt-Used"); } catch (e) {} dump(`routed is ${routed}\n`); Assert.equal(routed, "0"); let httpVersion = ""; try { httpVersion = request.protocolVersion; } catch (e) {} Assert.equal(httpVersion, "http/1.1"); // Resolve where run_next_test() used to be called this._resolve?.(request); } } // Factory to create { listener, promise } function createCheckFallbackListener() { let resolve, reject; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); const listener = new CheckFallbackListener(resolve, reject); return { listener, promise }; } // Server cancels request with VersionFallback. async function do_test_version_fallback(httpsOrigin) { dump("do_test_version_fallback()\n"); const chan = makeChan(httpsOrigin + "VersionFallback"); const { listener, promise } = createCheckFallbackListener(); chan.asyncOpen(listener); await promise; }