Source code
Revision control
Copy as Markdown
Other Tools
Test Info:
- Manifest: netwerk/test/unit/xpcshell.toml
/* Any copyright is dedicated to the Public Domain.
//
// A `Cache-Control: no-cache` response whose cache writer is suspended and then
// cancelled (as an address-bar autocomplete request is when the user keeps
// typing, and as tracking-protection annotation deferral can leave it -- bug
// 2030021) must not leave the cache entry perpetually "being written". If it
// does, every later same-URL request has to revalidate, can't use the pending
// entry, and parks in nsHttpChannel's AwaitingCacheCallbacks state forever.
//
// We reproduce the wedged writer by suspending the writer channel from its
// listener's onStartRequest (once the cache output stream is being opened) and
// cancelling it while still suspended (never resuming, modelling the annotation
// callback that never fires). We then issue a second no-cache request for the
// same URL and assert that it completes instead of hanging.
//
// End-to-end coverage note: this drives the real nsHttpChannel cache-wait path
// (RECHECK_AFTER_WRITE_FINISHED -> AwaitingCacheCallbacks). The consumer is
// released by the writer-lock bypass / cache-wait backstop fixes. Exercising
// the writer-side structural fix through its *specific* trigger (the ETP
// annotation suspend, mSuspendAfterExamineResponse) additionally requires the
// URL-classifier test-tracker setup plus
// privacy.trackingprotection.defer_annotation.enabled=true; see the block
// comment at the end for how to extend this test to that path.
"use strict";
const { HttpServer } = ChromeUtils.importESModule(
);
const { setTimeout } = ChromeUtils.importESModule(
"resource://gre/modules/Timer.sys.mjs"
);
let httpServer = null;
let baseUrl = null;
let requestCount = 0;
function noCacheHandler(metadata, response) {
requestCount++;
response.setHeader("Content-Type", "text/plain", false);
// Must revalidate before use -> later requests can't use a pending entry.
response.setHeader("Cache-Control", "no-cache", false);
const body = "suggestion-" + requestCount;
response.bodyOutputStream.write(body, body.length);
}
function makeChannel(uri) {
return NetUtil.newChannel({ uri, loadUsingSystemPrincipal: true });
}
function withTimeout(promise, ms, message) {
return Promise.race([
promise,
new Promise((_, reject) =>
// eslint-disable-next-line mozilla/no-arbitrary-setTimeout
setTimeout(() => reject(new Error(message)), ms)
),
]);
}
add_task(async function test_no_cache_writer_hang() {
// Fire the suspended-writer bypass quickly so the test doesn't wait on its
// production-sized default. Disable the consumer-side cache-wait backstop
// (set it to 0) so this test deterministically exercises exactly one release
// path -- the writer-lock bypass -- and fails if that regresses instead of
// being masked by the backstop.
Services.prefs.setIntPref("network.cache.suspended_writer_delay_ms", 500);
Services.prefs.setIntPref("network.cache.entry_wait_timeout_ms", 0);
registerCleanupFunction(() => {
Services.prefs.clearUserPref("network.cache.suspended_writer_delay_ms");
Services.prefs.clearUserPref("network.cache.entry_wait_timeout_ms");
});
// Strand the first (writer) channel as a cache writer whose output stream is
// never closed and whose entry is never doomed. We suspend it from its
// listener's onStartRequest -- NOT from http-on-examine-response: suspending
// that early makes ContinueProcessNormal2 defer the whole response (including
// opening the cache output stream) to resume, so the entry never becomes
// "being written". By onStartRequest we are past that deferral point;
// InstallCacheListener opens the cache output stream in the same synchronous
// unwind right after onStartRequest returns, so the entry reaches READY with
// its output stream open. We then cancel while suspended, which defers the
// channel's teardown (CloseCacheEntry / AsyncAbort) indefinitely -- exactly
// the wedged-writer state from the bug.
const writer = makeChannel(baseUrl);
let resolveWriterDone;
const writerDone = new Promise(resolve => {
resolveWriterDone = resolve;
});
const writerStranded = new Promise(resolveStranded => {
const listener = {
QueryInterface: ChromeUtils.generateQI([
"nsIStreamListener",
"nsIRequestObserver",
]),
onStartRequest() {
writer.suspend();
// Cancel on a fresh microtask, after the synchronous response
// processing (which opens the cache output stream) has unwound.
Promise.resolve().then(() => {
writer.cancel(Cr.NS_BINDING_ABORTED);
resolveStranded();
});
},
onDataAvailable(request, stream, offset, count) {
// Not expected while suspended, but drain defensively.
const bis = Cc["@mozilla.org/binaryinputstream;1"].createInstance(
Ci.nsIBinaryInputStream
);
bis.setInputStream(stream);
bis.readByteArray(count);
},
onStopRequest() {
resolveWriterDone();
},
};
writer.asyncOpen(listener);
});
await writerStranded;
// Second request for the same URL. With the bug it parks forever behind the
// zombie writer; with the fix it revalidates over the network and completes.
let consumerData;
try {
const consumer = makeChannel(baseUrl);
consumerData = await withTimeout(
new Promise(resolve => {
consumer.asyncOpen(
new ChannelListener(
(request, data) => resolve(data),
null,
CL_ALLOW_UNKNOWN_CL
)
);
}),
15000,
"consumer hung in AwaitingCacheCallbacks behind a wedged cache writer"
);
} finally {
// Resume the stranded writer so it can tear down cleanly (and not trip
// xpcshell's shutdown checks), whether the consumer was released -- the
// behavior under test -- or timed out because the fix is absent.
writer.resume();
await writerDone.catch(() => {});
}
Assert.greater(
consumerData.length,
0,
"consumer received a response instead of hanging"
);
});
function run_test() {
httpServer = new HttpServer();
httpServer.registerPathHandler("/sugg", noCacheHandler);
httpServer.start(-1);
registerCleanupFunction(() => {
return new Promise(resolve => httpServer.stop(resolve));
});
run_next_test();
}
// To exercise the writer-side structural fix (nsHttpChannel::CancelInternal
// undoing the tracking-annotation suspend) through its exact trigger rather
// than a generic channel.suspend():
//
// 1. Set prefs:
// privacy.trackingprotection.enabled = true
// privacy.trackingprotection.annotate_channels = true
// privacy.trackingprotection.defer_annotation.enabled = true
// 2. Register the served host on the URL classifier's tracking-annotation
// table (UrlClassifierTestUtils.addTestTrackers() and load it in a
// third-party context so NS_ShouldClassifyChannel(ETP) is true).
// 3. Drive the request so it becomes a cache writer, is suspended by
// MaybeSuspendAfterExamineResponse() while annotation is pending, and is
// cancelled before the annotation callback resolves.
// 4. Assert a second same-URL no-cache request completes (does not hang).
//
// That path is intentionally left out of the runnable test above because the
// classifier setup is substantially more involved; the scenario above already
// provides end-to-end regression coverage of the wedged-writer hang.