/* 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(
"resource://testing-common/httpd.sys.mjs"
);
const { NodeHTTPServer } = ChromeUtils.importESModule(
"resource://testing-common/NodeServer.sys.mjs"
);
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();
}
}
);
add_task(
{
skip_if: () =>
AppConstants.MOZ_SYSTEM_NSS ||
!gServerStarted ||
mozinfo.os ==
"android" ||
mozinfo.socketprocess_networking,
},
async
function test_he_0rtt_h2_txn_gone_closes_connection() {
const host =
"0rtt-reject-h2.example.com";
const TLS_PORT =
8443;
const url = `https:
//${host}:${TLS_PORT}/`;
let nss = Cc[
"@mozilla.org/psm;1"].getService(Ci.nsINSSComponent);
await nss.asyncClearSSLExternalAndInternalSessionCache();
override.clearOverrides();
override.addIPOverride(host,
"127.0.0.1");
Services.prefs.setBoolPref(
"network.dns.disableIPv6",
true);
try {
// ── Warm-up: full H2 handshake, session ticket issued ──────────────
const wu = fetchNoThrow(url);
const wuResult = await wu.promise;
Assert.ok(wuResult.ok,
"H2 warm-up must succeed");
// Wait for the session ticket to arrive and be cached.
// eslint-disable-next-line mozilla/no-arbitrary-setTimeout
await
new Promise(r => setTimeout(r,
1000));
// Wait for the H2 idle timeout (2 s, set in add_setup) to reclaim the
// warm-up session so the next connection is forced to race fresh.
// eslint-disable-next-line mozilla/no-arbitrary-setTimeout
await
new Promise(r => setTimeout(r,
3000));
// ── Test: H2 0-RTT rejected, pref forces the null-realTxn path ────
Services.prefs.setBoolPref(
"network.http.0rtt_force_txn_gone_for_testing",
true
);
const f = fetchNoThrow(url);
const result = await f.promise;
Assert.ok(!result.ok,
"request should fail when null-txn path fires");
Assert.equal(
result.error,
Cr.NS_ERROR_ABORT,
"TCP connection should be closed cleanly with NS_ERROR_ABORT"
);
Services.prefs.clearUserPref(
"network.http.0rtt_force_txn_gone_for_testing"
);
// ── Recovery: networking stack must still be functional ────────────
Services.obs.notifyObservers(
null,
"net:cancel-all-connections");
// eslint-disable-next-line mozilla/no-arbitrary-setTimeout
await
new Promise(r => setTimeout(r,
200));
const r3 = fetchNoThrow(url);
const r3Result = await r3.promise;
Assert.ok(r3Result.ok,
"recovery request should succeed");
Assert.equal(r3Result.status,
200,
"recovery request should return 200");
}
finally {
Services.prefs.clearUserPref(
"network.http.0rtt_force_txn_gone_for_testing"
);
Services.prefs.clearUserPref(
"network.dns.disableIPv6");
Services.obs.notifyObservers(
null,
"net:cancel-all-connections");
// eslint-disable-next-line mozilla/no-arbitrary-setTimeout
await
new Promise(r => setTimeout(r,
200));
override.clearOverrides();
}
}
);