Source code
Revision control
Copy as Markdown
Other Tools
Test Info:
- Manifest: netwerk/test/unit/xpcshell.toml
/**
* Tests for HTTP Compression Dictionary replacement functionality
* - Verify that when a dictionary resource is reloaded without Use-As-Dictionary,
* the dictionary metadata is properly removed
* - Test that Available-Dictionary header is no longer sent for matching resources
* after dictionary is replaced with non-dictionary content
*
* This tests the fix for the race condition in DictionaryOriginReader::OnCacheEntryAvailable
* where mEntry was not set for existing origins loaded from disk.
*/
"use strict";
// Load cache helpers
const { NodeHTTPSServer } = ChromeUtils.importESModule(
);
const DICTIONARY_CONTENT = "DICTIONARY_CONTENT_FOR_COMPRESSION_TEST_DATA";
const REPLACEMENT_CONTENT = "REPLACED_CONTENT_WITHOUT_DICTIONARY_HEADER";
let server = null;
add_setup(async function () {
Services.prefs.setBoolPref("network.http.dictionaries.enable", true);
server = new NodeHTTPSServer();
await server.start();
// Clear any existing cache
let lci = Services.loadContextInfo.custom(false, {
partitionKey: `(https,localhost)`,
});
evict_cache_entries("all", lci);
registerCleanupFunction(async () => {
try {
await server.stop();
} catch (e) {
// Ignore server stop errors during cleanup
}
});
});
function makeChan(url, bypassCache = false) {
let chan = NetUtil.newChannel({
uri: url,
loadUsingSystemPrincipal: true,
contentPolicyType: Ci.nsIContentPolicy.TYPE_DOCUMENT,
}).QueryInterface(Ci.nsIHttpChannel);
if (bypassCache) {
chan.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE;
}
return chan;
}
function channelOpenPromise(chan, intermittentFail = false) {
return new Promise(resolve => {
function finish(req, buffer) {
resolve([req, buffer]);
}
if (intermittentFail) {
chan.asyncOpen(
new SimpleChannelListener(finish, null, CL_ALLOW_UNKNOWN_CL)
);
} else {
chan.asyncOpen(new ChannelListener(finish, null, CL_ALLOW_UNKNOWN_CL));
}
});
}
function verifyDictionaryStored(url, shouldExist) {
return new Promise(resolve => {
let lci = Services.loadContextInfo.custom(false, {
partitionKey: `(https,localhost)`,
});
asyncCheckCacheEntryPresence(url, "disk", shouldExist, lci, resolve);
});
}
function syncCache() {
return new Promise(resolve => {
syncWithCacheIOThread(resolve, true);
});
}
// Clear in-memory DictionaryCache and purge cache entries from memory.
// This forces dictionary origin entries to be reloaded from disk on next access,
// triggering DictionaryOriginReader::OnCacheEntryAvailable.
async function clearDictionaryCacheAndPurgeMemory() {
// Clear the DictionaryCache in-memory hashmap
let testingInterface = Services.cache2.QueryInterface(Ci.nsICacheTesting);
testingInterface.clearDictionaryCacheMemory();
// Force GC to release references to cache entries. Probably not strictly needed
gc();
}
/**
* Test that replacing a dictionary resource with non-dictionary content
* properly removes the dictionary metadata.
*
* Steps:
* 1. Load a resource with Use-As-Dictionary header (creates dictionary entry)
* 2. Verify Available-Dictionary is sent for matching resources
* 3. Force-reload the dictionary resource WITHOUT Use-As-Dictionary
* 4. Verify Available-Dictionary is NO LONGER sent for matching resources
*/
add_task(async function test_dictionary_replacement_removes_metadata() {
// Track Available-Dictionary headers received by server
let receivedAvailableDictionary = null;
// Register dictionary endpoint that returns dictionary content
await server.registerPathHandler(
"/dict/resource",
function (request, response) {
response.writeHead(200, {
"Content-Type": "application/octet-stream",
"Use-As-Dictionary": 'match="/matching/*", id="test-dict", type=raw',
"Cache-Control": "max-age=3600",
});
response.end("DICTIONARY_CONTENT_FOR_COMPRESSION_TEST_DATA", "binary");
}
);
// Register matching resource endpoint
await server.registerPathHandler(
"/matching/test",
function (request, response) {
// Store the Available-Dictionary header value in global for later retrieval
global.lastAvailableDictionary =
request.headers["available-dictionary"] || null;
response.writeHead(200, {
"Content-Type": "text/plain",
"Cache-Control": "no-cache",
});
response.end("CONTENT_THAT_SHOULD_MATCH_DICTIONARY", "binary");
}
);
dump("**** Step 1: Load dictionary resource with Use-As-Dictionary\n");
let chan = makeChan(dictUrl);
let [, data] = await channelOpenPromise(chan);
Assert.equal(data, DICTIONARY_CONTENT, "Dictionary content should match");
// Verify dictionary is stored in cache
await verifyDictionaryStored(dictUrl, true);
// Sync to ensure everything is written to disk
await syncCache();
dump("**** Step 1.5: Clear in-memory caches to force reload from disk\n");
// Clear in-memory DictionaryCache and purge cache entries from memory.
// This forces dictionary entries to be reloaded from disk via
// DictionaryOriginReader::OnCacheEntryAvailable, which is the code path
// with the bug we're testing.
await clearDictionaryCacheAndPurgeMemory();
dump(
"**** Step 2: Verify Available-Dictionary is sent for matching resource\n"
);
chan = makeChan(matchingUrl);
await channelOpenPromise(chan);
// Get the Available-Dictionary value from the server
receivedAvailableDictionary = await server.execute(
"global.lastAvailableDictionary"
);
Assert.notStrictEqual(
receivedAvailableDictionary,
null,
"Available-Dictionary header should be sent for matching resource"
);
Assert.ok(
receivedAvailableDictionary.includes(":"),
"Available-Dictionary should contain a hash"
);
dump(`**** Received Available-Dictionary: ${receivedAvailableDictionary}\n`);
dump(
"**** Step 3: Force-reload dictionary resource WITHOUT Use-As-Dictionary\n"
);
// Re-register the dictionary endpoint to return content WITHOUT Use-As-Dictionary
await server.registerPathHandler(
"/dict/resource",
function (request, response) {
response.writeHead(200, {
"Content-Type": "application/octet-stream",
"Cache-Control": "max-age=3600",
// No Use-As-Dictionary header!
});
response.end("REPLACED_CONTENT_WITHOUT_DICTIONARY_HEADER", "binary");
}
);
chan = makeChan(dictUrl, true /* bypassCache */);
[, data] = await channelOpenPromise(chan);
Assert.equal(data, REPLACEMENT_CONTENT, "Replacement content should match");
// Sync to ensure cache operations complete
await syncCache();
dump("**** Step 4: Verify Available-Dictionary is NOT sent anymore\n");
// Reset the server's stored value
await server.execute("global.lastAvailableDictionary = null");
// Now request the matching resource again
chan = makeChan(matchingUrl);
await channelOpenPromise(chan);
receivedAvailableDictionary = await server.execute(
"global.lastAvailableDictionary"
);
Assert.equal(
receivedAvailableDictionary,
null,
"Available-Dictionary header should NOT be sent after dictionary is replaced"
);
dump("**** Test passed: Dictionary metadata was properly removed\n");
});
/**
* Test the same scenario but with gzip-compressed replacement content.
* This simulates the real-world case where a server might return
* compressed content without Use-As-Dictionary.
*/
add_task(async function test_dictionary_replacement_with_compressed_content() {
dump("**** Clear cache and start fresh\n");
let lci = Services.loadContextInfo.custom(false, {
partitionKey: `(https,localhost)`,
});
evict_cache_entries("all", lci);
// Also clear in-memory DictionaryCache to start fresh
let testingInterface = Services.cache2.QueryInterface(Ci.nsICacheTesting);
testingInterface.clearDictionaryCacheMemory();
await syncCache();
let receivedAvailableDictionary = null;
// Register dictionary endpoint
await server.registerPathHandler(
"/dict/compressed",
function (request, response) {
response.writeHead(200, {
"Content-Type": "application/octet-stream",
"Use-As-Dictionary":
'match="/compressed-match/*", id="compressed-dict", type=raw',
"Cache-Control": "max-age=3600",
});
response.end("DICTIONARY_FOR_COMPRESSED_TEST", "binary");
}
);
// Register matching resource endpoint
await server.registerPathHandler(
"/compressed-match/test",
function (request, response) {
global.lastCompressedAvailDict =
request.headers["available-dictionary"] || null;
response.writeHead(200, {
"Content-Type": "text/plain",
"Cache-Control": "no-cache",
});
response.end("MATCHING_CONTENT", "binary");
}
);
dump("**** Step 1: Load dictionary resource with Use-As-Dictionary\n");
let chan = makeChan(dictUrl);
let [, data] = await channelOpenPromise(chan);
Assert.equal(
data,
"DICTIONARY_FOR_COMPRESSED_TEST",
"Dictionary content should match"
);
await verifyDictionaryStored(dictUrl, true);
await syncCache();
dump("**** Step 1.5: Clear in-memory caches to force reload from disk\n");
await clearDictionaryCacheAndPurgeMemory();
dump("**** Step 2: Verify Available-Dictionary is sent\n");
chan = makeChan(matchingUrl);
await channelOpenPromise(chan);
receivedAvailableDictionary = await server.execute(
"global.lastCompressedAvailDict"
);
Assert.notStrictEqual(
receivedAvailableDictionary,
null,
"Available-Dictionary should be sent initially"
);
dump(
"**** Step 3: Force-reload with gzip-compressed content (no Use-As-Dictionary)\n"
);
// Re-register to return gzip-compressed content without Use-As-Dictionary
await server.registerPathHandler(
"/dict/compressed",
function (request, response) {
// Gzip-compressed version of "GZIP_COMPRESSED_REPLACEMENT"
// Using Node.js zlib in the handler
const zlib = require("zlib");
const compressed = zlib.gzipSync("GZIP_COMPRESSED_REPLACEMENT");
response.writeHead(200, {
"Content-Type": "application/octet-stream",
"Content-Encoding": "gzip",
"Cache-Control": "max-age=3600",
// No Use-As-Dictionary header!
});
response.end(compressed);
}
);
chan = makeChan(dictUrl, true /* bypassCache */);
[, data] = await channelOpenPromise(chan);
// Content should be decompressed by the channel
Assert.equal(
data,
"GZIP_COMPRESSED_REPLACEMENT",
"Decompressed replacement content should match"
);
dump("**** Step 4: Verify Available-Dictionary is NOT sent anymore\n");
await server.execute("global.lastCompressedAvailDict = null");
chan = makeChan(matchingUrl);
await channelOpenPromise(chan);
receivedAvailableDictionary = await server.execute(
"global.lastCompressedAvailDict"
);
Assert.equal(
receivedAvailableDictionary,
null,
"Available-Dictionary should NOT be sent after dictionary replaced with compressed content"
);
dump(
"**** Test passed: Dictionary metadata removed even with compressed replacement\n"
);
});
/**
* Test that multiple sequential replacements work correctly.
* Dictionary -> Non-dictionary -> Dictionary -> Non-dictionary
*/
add_task(async function test_dictionary_multiple_replacements() {
dump("**** Clear cache and start fresh\n");
let lci = Services.loadContextInfo.custom(false, {
partitionKey: `(https,localhost)`,
});
evict_cache_entries("all", lci);
// Also clear in-memory DictionaryCache to start fresh
let testingInterface = Services.cache2.QueryInterface(Ci.nsICacheTesting);
testingInterface.clearDictionaryCacheMemory();
await syncCache();
let receivedAvailableDictionary = null;
// Register matching resource endpoint
await server.registerPathHandler(
"/multi-match/test",
function (request, response) {
global.lastMultiAvailDict =
request.headers["available-dictionary"] || null;
response.writeHead(200, {
"Content-Type": "text/plain",
"Cache-Control": "no-cache",
});
response.end("MULTI_MATCHING_CONTENT", "binary");
}
);
// === First: Load as dictionary ===
dump("**** Load as dictionary (first time)\n");
await server.registerPathHandler("/dict/multi", function (request, response) {
response.writeHead(200, {
"Content-Type": "application/octet-stream",
"Use-As-Dictionary":
'match="/multi-match/*", id="multi-dict-1", type=raw',
"Cache-Control": "max-age=3600",
});
response.end("DICTIONARY_CONTENT_V1", "binary");
});
let chan = makeChan(dictUrl);
await channelOpenPromise(chan);
await syncCache();
// Clear in-memory caches to force reload from disk
await clearDictionaryCacheAndPurgeMemory();
chan = makeChan(matchingUrl);
await channelOpenPromise(chan);
receivedAvailableDictionary = await server.execute(
"global.lastMultiAvailDict"
);
Assert.notStrictEqual(
receivedAvailableDictionary,
null,
"Available-Dictionary should be sent (first dictionary)"
);
// === Second: Replace with non-dictionary ===
dump("**** Replace with non-dictionary\n");
await server.registerPathHandler("/dict/multi", function (request, response) {
response.writeHead(200, {
"Content-Type": "application/octet-stream",
"Cache-Control": "max-age=3600",
});
response.end("NON_DICTIONARY_CONTENT", "binary");
});
chan = makeChan(dictUrl, true);
await channelOpenPromise(chan);
await syncCache();
await new Promise(resolve => do_timeout(200, resolve));
await server.execute("global.lastMultiAvailDict = null");
chan = makeChan(matchingUrl);
await channelOpenPromise(chan);
receivedAvailableDictionary = await server.execute(
"global.lastMultiAvailDict"
);
Assert.equal(
receivedAvailableDictionary,
null,
"Available-Dictionary should NOT be sent (after first replacement)"
);
// === Third: Load as dictionary again ===
dump("**** Load as dictionary (second time)\n");
await server.registerPathHandler("/dict/multi", function (request, response) {
response.writeHead(200, {
"Content-Type": "application/octet-stream",
"Use-As-Dictionary":
'match="/multi-match/*", id="multi-dict-2", type=raw',
"Cache-Control": "max-age=3600",
});
response.end("DICTIONARY_CONTENT_V2", "binary");
});
chan = makeChan(dictUrl, true);
await channelOpenPromise(chan);
await syncCache();
// Clear in-memory caches to force reload from disk
await clearDictionaryCacheAndPurgeMemory();
chan = makeChan(matchingUrl);
await channelOpenPromise(chan);
receivedAvailableDictionary = await server.execute(
"global.lastMultiAvailDict"
);
Assert.notStrictEqual(
receivedAvailableDictionary,
null,
"Available-Dictionary should be sent (second dictionary)"
);
// === Fourth: Replace with non-dictionary again ===
dump("**** Replace with non-dictionary again\n");
await server.registerPathHandler("/dict/multi", function (request, response) {
response.writeHead(200, {
"Content-Type": "application/octet-stream",
"Cache-Control": "max-age=3600",
});
response.end("NON_DICTIONARY_CONTENT_V2", "binary");
});
chan = makeChan(dictUrl, true);
await channelOpenPromise(chan);
await syncCache();
await new Promise(resolve => do_timeout(200, resolve));
await server.execute("global.lastMultiAvailDict = null");
chan = makeChan(matchingUrl);
await channelOpenPromise(chan);
receivedAvailableDictionary = await server.execute(
"global.lastMultiAvailDict"
);
Assert.equal(
receivedAvailableDictionary,
null,
"Available-Dictionary should NOT be sent (after second replacement)"
);
dump("**** Test passed: Multiple replacements work correctly\n");
});
/**
* Test that hash mismatch during dictionary load causes the request to fail
* and the corrupted dictionary entry to be removed.
*
* Steps:
* 1. Load a resource with Use-As-Dictionary header (creates dictionary entry)
* 2. Verify Available-Dictionary is sent for matching resources
* 3. Corrupt the hash using the testing API
* 4. Clear memory cache to force reload from disk
* 5. Request a matching resource - dictionary prefetch should fail
* 6. Verify the dictionary entry was removed (Available-Dictionary no longer sent)
*/
add_task(async function test_dictionary_hash_mismatch() {
dump("**** Clear cache and start fresh\n");
let lci = Services.loadContextInfo.custom(false, {
partitionKey: `(https,localhost)`,
});
evict_cache_entries("all", lci);
let testingInterface = Services.cache2.QueryInterface(Ci.nsICacheTesting);
testingInterface.clearDictionaryCacheMemory();
await syncCache();
let receivedAvailableDictionary = null;
// Register dictionary endpoint
await server.registerPathHandler(
"/dict/hash-test",
function (request, response) {
response.writeHead(200, {
"Content-Type": "application/octet-stream",
"Use-As-Dictionary":
'match="/hash-match/*", id="hash-test-dict", type=raw',
"Cache-Control": "max-age=3600",
});
response.end("DICTIONARY_FOR_HASH_TEST", "binary");
}
);
// Register matching resource endpoint
await server.registerPathHandler(
"/hash-match/test",
function (request, response) {
global.lastHashTestAvailDict =
request.headers["available-dictionary"] || null;
response.writeHead(200, {
"Content-Type": "text/plain",
"Cache-Control": "no-cache",
});
response.end("HASH_MATCHING_CONTENT", "binary");
}
);
dump("**** Step 1: Load dictionary resource with Use-As-Dictionary\n");
let chan = makeChan(dictUrl);
let [, data] = await channelOpenPromise(chan);
Assert.equal(
data,
"DICTIONARY_FOR_HASH_TEST",
"Dictionary content should match"
);
await verifyDictionaryStored(dictUrl, true);
await syncCache();
dump("**** Step 2: Verify Available-Dictionary is sent\n");
chan = makeChan(matchingUrl);
await channelOpenPromise(chan);
receivedAvailableDictionary = await server.execute(
"global.lastHashTestAvailDict"
);
Assert.notStrictEqual(
receivedAvailableDictionary,
null,
"Available-Dictionary should be sent initially"
);
dump("**** Step 3: Corrupt the dictionary hash\n");
testingInterface.corruptDictionaryHash(dictUrl);
dump("**** Step 4: Clear dictionary data to force reload from disk\n");
// Clear dictionary data while keeping the corrupted hash.
// When next prefetch happens, data will be reloaded and compared
// against the corrupted hash, causing a mismatch.
testingInterface.clearDictionaryDataForTesting(dictUrl);
dump(
"**** Step 5: Request matching resource - should fail due to hash mismatch\n"
);
await server.execute("global.lastHashTestAvailDict = null");
// The request for the matching resource will try to prefetch the dictionary,
// which will fail due to hash mismatch. The channel should be cancelled.
chan = makeChan(matchingUrl);
try {
await channelOpenPromise(chan, true); // intermittent failure
} catch (e) {
dump(`**** Request failed with: ${e}\n`);
}
// Note: The request may or may not fail depending on timing. The important
// thing is that the dictionary entry should be removed.
dump("**** Step 6: Verify dictionary entry was removed\n");
// Wait a bit for the removal to complete
await syncCache();
await server.execute("global.lastHashTestAvailDict = null");
chan = makeChan(matchingUrl);
await channelOpenPromise(chan);
receivedAvailableDictionary = await server.execute(
"global.lastHashTestAvailDict"
);
Assert.equal(
receivedAvailableDictionary,
null,
"Available-Dictionary should NOT be sent after dictionary was removed due to hash mismatch"
);
dump("**** Test passed: Hash mismatch properly handled\n");
});
// Cleanup
add_task(async function cleanup() {
let lci = Services.loadContextInfo.custom(false, {
partitionKey: `(https,localhost)`,
});
evict_cache_entries("all", lci);
dump("**** All dictionary replacement tests completed\n");
});