Source code

Revision control

Copy as Markdown

Other Tools

Test Info: Warnings

/* 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/. */
// Regression test: TCP 0-RTT with alpnChanged=1 (H2 ticket, server responds H1)
// must not declare the dying connection the HE winner and insert it into
// mActiveConns. With mUsingSpdy=true on the CM entry, RestrictConnections()
// would permanently block all new connection attempts for the host.
//
// Setup: warm-up over IPv4-only establishes mUsingSpdy=true and leaves an H2
// session ticket; the race (IPv4 0 ms, IPv6 100 ms) fires 0-RTT with that
// ticket while the server now negotiates H1, triggering alpnChanged=1.
"use strict";
const { AppConstants } = ChromeUtils.importESModule(
"resource://gre/modules/AppConstants.sys.mjs"
);
const { HttpServer } = ChromeUtils.importESModule(
);
const { NodeHTTPServer } = ChromeUtils.importESModule(
);
var { setTimeout } = ChromeUtils.importESModule(
"resource://gre/modules/Timer.sys.mjs"
);
const override = Cc["@mozilla.org/network/native-dns-override;1"].getService(
Ci.nsINativeDNSResolverOverride
);
let callbackServer;
let gServerStarted = false;
add_setup(
{
skip_if: () => AppConstants.MOZ_SYSTEM_NSS,
},
async () => {
callbackServer = new HttpServer();
callbackServer.registerPrefixHandler("/callback/", () => {});
callbackServer.start(-1);
Services.env.set(
"MOZ_ZERORTT_ACCEPT_CALLBACK_PORT",
callbackServer.identity.primaryPort
);
Services.env.set("MOZ_TLS_SERVER_0RTT", "1");
const started = await asyncStartTLSTestServer(
"ZeroRttAcceptServer",
"../../../security/manager/ssl/tests/unit/test_faulty_server"
);
if (!started) {
return;
}
gServerStarted = true;
let nss = Cc["@mozilla.org/psm;1"].getService(Ci.nsINSSComponent);
await nss.asyncClearSSLExternalAndInternalSessionCache();
Services.prefs.setBoolPref("network.http.happy_eyeballs_enabled", true);
Services.prefs.setIntPref("network.http.speculative-parallel-limit", 0);
Services.prefs.setBoolPref("network.ssl_tokens_cache_enabled", true);
Services.prefs.setBoolPref("network.http.http3.enable", false);
// Make the H2 idle timeout very short so the warm-up H2 session is
// reclaimed quickly, leaving the CM entry alive (mUsingSpdy=true) but
// mActiveConns empty before the race starts.
Services.prefs.setIntPref("network.http.http2.timeout", 2);
registerCleanupFunction(async () => {
Services.prefs.clearUserPref("network.http.happy_eyeballs_enabled");
Services.prefs.clearUserPref("network.http.speculative-parallel-limit");
Services.prefs.clearUserPref("network.ssl_tokens_cache_enabled");
Services.prefs.clearUserPref("network.http.http3.enable");
Services.prefs.clearUserPref("network.http.http2.timeout");
Services.prefs.clearUserPref("network.dns.disableIPv6");
override.clearOverrides();
if (callbackServer) {
await callbackServer.stop();
callbackServer = null;
}
});
}
);
// Mutable proxy: constant port (same ConnectionInfo key throughout) with
// configurable per-family delay.
async function startMutableProxy(node) {
return node.execute(`
(function() {
const net = require("net");
global.__proxy = { ipv6Ms: 0, ipv4Ms: 0, p6: null, p4: null };
function fwd(client, family) {
const ms = global.__proxy[family + "Ms"];
let buf = [], dead = false;
client.on("data", c => buf.push(c));
["error","end","close"].forEach(e => client.on(e, () => { dead = true; }));
setTimeout(() => {
if (dead) { try { client.destroy(); } catch(_) {} return; }
const b = net.connect(8443, "127.0.0.1", () => {
buf.forEach(c => b.write(c)); buf = null;
client.removeAllListeners("data");
client.on("data", c => b.write(c));
b.on("data", c => { try { client.write(c); } catch(_) {} });
b.on("end", () => { try { client.end(); } catch(_) {} });
client.on("end", () => b.end());
b.on("error", () => client.destroy());
client.on("error", () => b.destroy());
});
b.on("error", () => client.destroy());
}, ms);
}
const p6 = net.createServer(s => fwd(s, "ipv6"));
const p4 = net.createServer(s => fwd(s, "ipv4"));
global.__proxy.p6 = p6; global.__proxy.p4 = p4;
return new Promise((res, rej) => {
p6.once("error", rej);
p6.listen(0, "::1", () => {
const port = p6.address().port;
p4.once("error", rej);
p4.listen(port, "127.0.0.1", () => res(port));
});
});
})()
`);
}
async function setProxyDelay(node, ipv6Ms, ipv4Ms) {
await node.execute(
`global.__proxy.ipv6Ms=${ipv6Ms}; global.__proxy.ipv4Ms=${ipv4Ms};`
);
}
async function stopProxy(node) {
await node.execute(`
if (global.__proxy) {
global.__proxy.p6.close(); global.__proxy.p4.close();
global.__proxy = null;
}
`);
}
function fetchNoThrow(url) {
const chan = NetUtil.newChannel({
uri: url,
loadUsingSystemPrincipal: true,
}).QueryInterface(Ci.nsIHttpChannel);
chan.loadFlags =
Ci.nsIChannel.LOAD_INITIAL_DOCUMENT_URI | Ci.nsIRequest.LOAD_BYPASS_CACHE;
const promise = new Promise(resolve => {
chan.asyncOpen({
onStartRequest() {},
onDataAvailable(_req, stream, _offset, count) {
read_stream(stream, count);
},
onStopRequest(req, status) {
if (Components.isSuccessCode(status)) {
resolve({
ok: true,
status: req.QueryInterface(Ci.nsIHttpChannel).responseStatus,
});
} else {
resolve({ ok: false, status: 0, error: status });
}
},
QueryInterface: ChromeUtils.generateQI(["nsIStreamListener"]),
});
});
return { chan, promise };
}
add_task(
{
skip_if: () =>
AppConstants.MOZ_SYSTEM_NSS ||
!gServerStarted ||
mozinfo.os == "android" ||
mozinfo.socketprocess_networking,
},
async function test_he_0rtt_alpn_switch_dead_winner_blocks_host() {
// Warm-up (IPv4-only) sets mUsingSpdy=true on the CM entry and issues an
// H2 session ticket. After the idle timeout, the CM entry stays alive
// (mUsingSpdy=true, mActiveConns empty). The race fires 0-RTT with that
// ticket; the server responds H1 → alpnChanged=1 → Finish0RTT fires.
const host = "0rtt-alpn-switch.example.com";
const node = new NodeHTTPServer();
await node.start();
try {
let nss = Cc["@mozilla.org/psm;1"].getService(Ci.nsINSSComponent);
await nss.asyncClearSSLExternalAndInternalSessionCache();
override.clearOverrides();
override.addIPOverride(host, "::1");
override.addIPOverride(host, "127.0.0.1");
// One proxy, constant port, so warm-up and race share the same
// ConnectionInfo key (and therefore the same CM entry / mUsingSpdy flag).
const port = await startMutableProxy(node);
const url = `https://${host}:${port}/`;
// ── Warm-up (IPv4 only, 0 ms delay) ──────────────────────────────────
// Server handles connection #0 as H2, issues a session ticket.
// ReportSpdyConnection sets mUsingSpdy=true on the CM entry.
Services.prefs.setBoolPref("network.dns.disableIPv6", true);
const wu = fetchNoThrow(url);
const wuResult = await wu.promise;
Assert.ok(wuResult.ok, "warm-up must succeed");
Services.prefs.setBoolPref("network.dns.disableIPv6", false);
// Let the H2 idle timeout (2 s) fire so the warm-up H2 session is
// reclaimed and mActiveConns becomes empty. The CM entry stays alive
// (mUsingSpdy=true) — DO NOT call net:cancel-all-connections, which
// would remove the entry from mCT and lose mUsingSpdy=true.
// eslint-disable-next-line mozilla/no-arbitrary-setTimeout
await new Promise(r => setTimeout(r, 3000));
// ── Race (IPv6 100 ms, IPv4 0 ms) ────────────────────────────────────
// Server uses H1 for all subsequent connections (gAlpnSwitchCount >= 1).
// Client's H2 ticket → 0-RTT; server responds H1 → alpnChanged=1 →
// Finish0RTT(restart=1) fires.
//
// Without the fix: HE declares the dying connection the winner, inserting
// it into mActiveConns. npnPending=true (only Start0RTTSpdy ran) →
// RestrictConnections() permanently blocks the re-queued transaction.
//
// Only one request: with multiple requests, other HCAs (no ticket, plain
// H1) would win and remove the dead winner via MakeAllDontReuseExcept,
// self-healing the deadlock before the timeout fires.
await setProxyDelay(node, 100, 0);
const TIMEOUT_MS = 10000;
let timedOut = false;
const f1 = fetchNoThrow(url);
const result = await Promise.race([
f1.promise,
new Promise(r =>
// eslint-disable-next-line mozilla/no-arbitrary-setTimeout
setTimeout(() => {
timedOut = true;
r(null);
}, TIMEOUT_MS)
),
]);
if (timedOut) {
try {
f1.chan.cancel(Cr.NS_ERROR_ABORT);
} catch (_) {}
await f1.promise;
}
Assert.ok(
!timedOut,
"Request must complete — without the fix, Finish0RTT(alpnChanged=1) " +
"inserts the dead connection into mActiveConns and " +
"RestrictConnections() permanently blocks new connections for this host."
);
if (!timedOut) {
Assert.ok(result.ok, "request should return HTTP 200");
}
} finally {
Services.obs.notifyObservers(null, "net:cancel-all-connections");
// eslint-disable-next-line mozilla/no-arbitrary-setTimeout
await new Promise(r => setTimeout(r, 200));
await stopProxy(node);
await node.stop();
override.clearOverrides();
}
}
);