test_dictionary_replacement.js (21065B)
1 /** 2 * Tests for HTTP Compression Dictionary replacement functionality 3 * - Verify that when a dictionary resource is reloaded without Use-As-Dictionary, 4 * the dictionary metadata is properly removed 5 * - Test that Available-Dictionary header is no longer sent for matching resources 6 * after dictionary is replaced with non-dictionary content 7 * 8 * This tests the fix for the race condition in DictionaryOriginReader::OnCacheEntryAvailable 9 * where mEntry was not set for existing origins loaded from disk. 10 */ 11 12 "use strict"; 13 14 // Load cache helpers 15 Services.scriptloader.loadSubScript("resource://test/head_cache.js", this); 16 17 const { NodeHTTPSServer } = ChromeUtils.importESModule( 18 "resource://testing-common/NodeServer.sys.mjs" 19 ); 20 21 const DICTIONARY_CONTENT = "DICTIONARY_CONTENT_FOR_COMPRESSION_TEST_DATA"; 22 const REPLACEMENT_CONTENT = "REPLACED_CONTENT_WITHOUT_DICTIONARY_HEADER"; 23 24 let server = null; 25 26 add_setup(async function () { 27 Services.prefs.setBoolPref("network.http.dictionaries.enable", true); 28 29 server = new NodeHTTPSServer(); 30 await server.start(); 31 32 // Clear any existing cache 33 let lci = Services.loadContextInfo.custom(false, { 34 partitionKey: `(https,localhost)`, 35 }); 36 evict_cache_entries("all", lci); 37 38 registerCleanupFunction(async () => { 39 try { 40 await server.stop(); 41 } catch (e) { 42 // Ignore server stop errors during cleanup 43 } 44 }); 45 }); 46 47 function makeChan(url, bypassCache = false) { 48 let chan = NetUtil.newChannel({ 49 uri: url, 50 loadUsingSystemPrincipal: true, 51 contentPolicyType: Ci.nsIContentPolicy.TYPE_DOCUMENT, 52 }).QueryInterface(Ci.nsIHttpChannel); 53 54 if (bypassCache) { 55 chan.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE; 56 } 57 58 return chan; 59 } 60 61 function channelOpenPromise(chan, intermittentFail = false) { 62 return new Promise(resolve => { 63 function finish(req, buffer) { 64 resolve([req, buffer]); 65 } 66 if (intermittentFail) { 67 chan.asyncOpen( 68 new SimpleChannelListener(finish, null, CL_ALLOW_UNKNOWN_CL) 69 ); 70 } else { 71 chan.asyncOpen(new ChannelListener(finish, null, CL_ALLOW_UNKNOWN_CL)); 72 } 73 }); 74 } 75 76 function verifyDictionaryStored(url, shouldExist) { 77 return new Promise(resolve => { 78 let lci = Services.loadContextInfo.custom(false, { 79 partitionKey: `(https,localhost)`, 80 }); 81 asyncCheckCacheEntryPresence(url, "disk", shouldExist, lci, resolve); 82 }); 83 } 84 85 function syncCache() { 86 return new Promise(resolve => { 87 syncWithCacheIOThread(resolve, true); 88 }); 89 } 90 91 // Clear in-memory DictionaryCache and purge cache entries from memory. 92 // This forces dictionary origin entries to be reloaded from disk on next access, 93 // triggering DictionaryOriginReader::OnCacheEntryAvailable. 94 async function clearDictionaryCacheAndPurgeMemory() { 95 // Clear the DictionaryCache in-memory hashmap 96 let testingInterface = Services.cache2.QueryInterface(Ci.nsICacheTesting); 97 testingInterface.clearDictionaryCacheMemory(); 98 99 // Force GC to release references to cache entries. Probably not strictly needed 100 gc(); 101 } 102 103 /** 104 * Test that replacing a dictionary resource with non-dictionary content 105 * properly removes the dictionary metadata. 106 * 107 * Steps: 108 * 1. Load a resource with Use-As-Dictionary header (creates dictionary entry) 109 * 2. Verify Available-Dictionary is sent for matching resources 110 * 3. Force-reload the dictionary resource WITHOUT Use-As-Dictionary 111 * 4. Verify Available-Dictionary is NO LONGER sent for matching resources 112 */ 113 add_task(async function test_dictionary_replacement_removes_metadata() { 114 // Track Available-Dictionary headers received by server 115 let receivedAvailableDictionary = null; 116 117 // Register dictionary endpoint that returns dictionary content 118 await server.registerPathHandler( 119 "/dict/resource", 120 function (request, response) { 121 response.writeHead(200, { 122 "Content-Type": "application/octet-stream", 123 "Use-As-Dictionary": 'match="/matching/*", id="test-dict", type=raw', 124 "Cache-Control": "max-age=3600", 125 }); 126 response.end("DICTIONARY_CONTENT_FOR_COMPRESSION_TEST_DATA", "binary"); 127 } 128 ); 129 130 // Register matching resource endpoint 131 await server.registerPathHandler( 132 "/matching/test", 133 function (request, response) { 134 // Store the Available-Dictionary header value in global for later retrieval 135 global.lastAvailableDictionary = 136 request.headers["available-dictionary"] || null; 137 response.writeHead(200, { 138 "Content-Type": "text/plain", 139 "Cache-Control": "no-cache", 140 }); 141 response.end("CONTENT_THAT_SHOULD_MATCH_DICTIONARY", "binary"); 142 } 143 ); 144 145 dump("**** Step 1: Load dictionary resource with Use-As-Dictionary\n"); 146 147 let dictUrl = `https://localhost:${server.port()}/dict/resource`; 148 let chan = makeChan(dictUrl); 149 let [, data] = await channelOpenPromise(chan); 150 151 Assert.equal(data, DICTIONARY_CONTENT, "Dictionary content should match"); 152 153 // Verify dictionary is stored in cache 154 await verifyDictionaryStored(dictUrl, true); 155 156 // Sync to ensure everything is written to disk 157 await syncCache(); 158 159 dump("**** Step 1.5: Clear in-memory caches to force reload from disk\n"); 160 161 // Clear in-memory DictionaryCache and purge cache entries from memory. 162 // This forces dictionary entries to be reloaded from disk via 163 // DictionaryOriginReader::OnCacheEntryAvailable, which is the code path 164 // with the bug we're testing. 165 await clearDictionaryCacheAndPurgeMemory(); 166 167 dump( 168 "**** Step 2: Verify Available-Dictionary is sent for matching resource\n" 169 ); 170 171 let matchingUrl = `https://localhost:${server.port()}/matching/test`; 172 chan = makeChan(matchingUrl); 173 await channelOpenPromise(chan); 174 175 // Get the Available-Dictionary value from the server 176 receivedAvailableDictionary = await server.execute( 177 "global.lastAvailableDictionary" 178 ); 179 180 Assert.notStrictEqual( 181 receivedAvailableDictionary, 182 null, 183 "Available-Dictionary header should be sent for matching resource" 184 ); 185 Assert.ok( 186 receivedAvailableDictionary.includes(":"), 187 "Available-Dictionary should contain a hash" 188 ); 189 190 dump(`**** Received Available-Dictionary: ${receivedAvailableDictionary}\n`); 191 192 dump( 193 "**** Step 3: Force-reload dictionary resource WITHOUT Use-As-Dictionary\n" 194 ); 195 196 // Re-register the dictionary endpoint to return content WITHOUT Use-As-Dictionary 197 await server.registerPathHandler( 198 "/dict/resource", 199 function (request, response) { 200 response.writeHead(200, { 201 "Content-Type": "application/octet-stream", 202 "Cache-Control": "max-age=3600", 203 // No Use-As-Dictionary header! 204 }); 205 response.end("REPLACED_CONTENT_WITHOUT_DICTIONARY_HEADER", "binary"); 206 } 207 ); 208 209 chan = makeChan(dictUrl, true /* bypassCache */); 210 [, data] = await channelOpenPromise(chan); 211 212 Assert.equal(data, REPLACEMENT_CONTENT, "Replacement content should match"); 213 214 // Sync to ensure cache operations complete 215 await syncCache(); 216 217 dump("**** Step 4: Verify Available-Dictionary is NOT sent anymore\n"); 218 219 // Reset the server's stored value 220 await server.execute("global.lastAvailableDictionary = null"); 221 222 // Now request the matching resource again 223 chan = makeChan(matchingUrl); 224 await channelOpenPromise(chan); 225 226 receivedAvailableDictionary = await server.execute( 227 "global.lastAvailableDictionary" 228 ); 229 230 Assert.equal( 231 receivedAvailableDictionary, 232 null, 233 "Available-Dictionary header should NOT be sent after dictionary is replaced" 234 ); 235 236 dump("**** Test passed: Dictionary metadata was properly removed\n"); 237 }); 238 239 /** 240 * Test the same scenario but with gzip-compressed replacement content. 241 * This simulates the real-world case where a server might return 242 * compressed content without Use-As-Dictionary. 243 */ 244 add_task(async function test_dictionary_replacement_with_compressed_content() { 245 dump("**** Clear cache and start fresh\n"); 246 let lci = Services.loadContextInfo.custom(false, { 247 partitionKey: `(https,localhost)`, 248 }); 249 evict_cache_entries("all", lci); 250 251 // Also clear in-memory DictionaryCache to start fresh 252 let testingInterface = Services.cache2.QueryInterface(Ci.nsICacheTesting); 253 testingInterface.clearDictionaryCacheMemory(); 254 255 await syncCache(); 256 257 let receivedAvailableDictionary = null; 258 259 // Register dictionary endpoint 260 await server.registerPathHandler( 261 "/dict/compressed", 262 function (request, response) { 263 response.writeHead(200, { 264 "Content-Type": "application/octet-stream", 265 "Use-As-Dictionary": 266 'match="/compressed-match/*", id="compressed-dict", type=raw', 267 "Cache-Control": "max-age=3600", 268 }); 269 response.end("DICTIONARY_FOR_COMPRESSED_TEST", "binary"); 270 } 271 ); 272 273 // Register matching resource endpoint 274 await server.registerPathHandler( 275 "/compressed-match/test", 276 function (request, response) { 277 global.lastCompressedAvailDict = 278 request.headers["available-dictionary"] || null; 279 response.writeHead(200, { 280 "Content-Type": "text/plain", 281 "Cache-Control": "no-cache", 282 }); 283 response.end("MATCHING_CONTENT", "binary"); 284 } 285 ); 286 287 dump("**** Step 1: Load dictionary resource with Use-As-Dictionary\n"); 288 289 let dictUrl = `https://localhost:${server.port()}/dict/compressed`; 290 let chan = makeChan(dictUrl); 291 let [, data] = await channelOpenPromise(chan); 292 293 Assert.equal( 294 data, 295 "DICTIONARY_FOR_COMPRESSED_TEST", 296 "Dictionary content should match" 297 ); 298 await verifyDictionaryStored(dictUrl, true); 299 await syncCache(); 300 301 dump("**** Step 1.5: Clear in-memory caches to force reload from disk\n"); 302 await clearDictionaryCacheAndPurgeMemory(); 303 304 dump("**** Step 2: Verify Available-Dictionary is sent\n"); 305 306 let matchingUrl = `https://localhost:${server.port()}/compressed-match/test`; 307 chan = makeChan(matchingUrl); 308 await channelOpenPromise(chan); 309 310 receivedAvailableDictionary = await server.execute( 311 "global.lastCompressedAvailDict" 312 ); 313 Assert.notStrictEqual( 314 receivedAvailableDictionary, 315 null, 316 "Available-Dictionary should be sent initially" 317 ); 318 319 dump( 320 "**** Step 3: Force-reload with gzip-compressed content (no Use-As-Dictionary)\n" 321 ); 322 323 // Re-register to return gzip-compressed content without Use-As-Dictionary 324 await server.registerPathHandler( 325 "/dict/compressed", 326 function (request, response) { 327 // Gzip-compressed version of "GZIP_COMPRESSED_REPLACEMENT" 328 // Using Node.js zlib in the handler 329 const zlib = require("zlib"); 330 const compressed = zlib.gzipSync("GZIP_COMPRESSED_REPLACEMENT"); 331 332 response.writeHead(200, { 333 "Content-Type": "application/octet-stream", 334 "Content-Encoding": "gzip", 335 "Cache-Control": "max-age=3600", 336 // No Use-As-Dictionary header! 337 }); 338 response.end(compressed); 339 } 340 ); 341 342 chan = makeChan(dictUrl, true /* bypassCache */); 343 [, data] = await channelOpenPromise(chan); 344 345 // Content should be decompressed by the channel 346 Assert.equal( 347 data, 348 "GZIP_COMPRESSED_REPLACEMENT", 349 "Decompressed replacement content should match" 350 ); 351 352 dump("**** Step 4: Verify Available-Dictionary is NOT sent anymore\n"); 353 354 await server.execute("global.lastCompressedAvailDict = null"); 355 chan = makeChan(matchingUrl); 356 await channelOpenPromise(chan); 357 358 receivedAvailableDictionary = await server.execute( 359 "global.lastCompressedAvailDict" 360 ); 361 362 Assert.equal( 363 receivedAvailableDictionary, 364 null, 365 "Available-Dictionary should NOT be sent after dictionary replaced with compressed content" 366 ); 367 368 dump( 369 "**** Test passed: Dictionary metadata removed even with compressed replacement\n" 370 ); 371 }); 372 373 /** 374 * Test that multiple sequential replacements work correctly. 375 * Dictionary -> Non-dictionary -> Dictionary -> Non-dictionary 376 */ 377 add_task(async function test_dictionary_multiple_replacements() { 378 dump("**** Clear cache and start fresh\n"); 379 let lci = Services.loadContextInfo.custom(false, { 380 partitionKey: `(https,localhost)`, 381 }); 382 evict_cache_entries("all", lci); 383 384 // Also clear in-memory DictionaryCache to start fresh 385 let testingInterface = Services.cache2.QueryInterface(Ci.nsICacheTesting); 386 testingInterface.clearDictionaryCacheMemory(); 387 388 await syncCache(); 389 390 let receivedAvailableDictionary = null; 391 392 // Register matching resource endpoint 393 await server.registerPathHandler( 394 "/multi-match/test", 395 function (request, response) { 396 global.lastMultiAvailDict = 397 request.headers["available-dictionary"] || null; 398 response.writeHead(200, { 399 "Content-Type": "text/plain", 400 "Cache-Control": "no-cache", 401 }); 402 response.end("MULTI_MATCHING_CONTENT", "binary"); 403 } 404 ); 405 406 let dictUrl = `https://localhost:${server.port()}/dict/multi`; 407 let matchingUrl = `https://localhost:${server.port()}/multi-match/test`; 408 409 // === First: Load as dictionary === 410 dump("**** Load as dictionary (first time)\n"); 411 await server.registerPathHandler("/dict/multi", function (request, response) { 412 response.writeHead(200, { 413 "Content-Type": "application/octet-stream", 414 "Use-As-Dictionary": 415 'match="/multi-match/*", id="multi-dict-1", type=raw', 416 "Cache-Control": "max-age=3600", 417 }); 418 response.end("DICTIONARY_CONTENT_V1", "binary"); 419 }); 420 421 let chan = makeChan(dictUrl); 422 await channelOpenPromise(chan); 423 await syncCache(); 424 425 // Clear in-memory caches to force reload from disk 426 await clearDictionaryCacheAndPurgeMemory(); 427 428 chan = makeChan(matchingUrl); 429 await channelOpenPromise(chan); 430 receivedAvailableDictionary = await server.execute( 431 "global.lastMultiAvailDict" 432 ); 433 Assert.notStrictEqual( 434 receivedAvailableDictionary, 435 null, 436 "Available-Dictionary should be sent (first dictionary)" 437 ); 438 439 // === Second: Replace with non-dictionary === 440 dump("**** Replace with non-dictionary\n"); 441 await server.registerPathHandler("/dict/multi", function (request, response) { 442 response.writeHead(200, { 443 "Content-Type": "application/octet-stream", 444 "Cache-Control": "max-age=3600", 445 }); 446 response.end("NON_DICTIONARY_CONTENT", "binary"); 447 }); 448 449 chan = makeChan(dictUrl, true); 450 await channelOpenPromise(chan); 451 await syncCache(); 452 await new Promise(resolve => do_timeout(200, resolve)); 453 454 await server.execute("global.lastMultiAvailDict = null"); 455 chan = makeChan(matchingUrl); 456 await channelOpenPromise(chan); 457 receivedAvailableDictionary = await server.execute( 458 "global.lastMultiAvailDict" 459 ); 460 Assert.equal( 461 receivedAvailableDictionary, 462 null, 463 "Available-Dictionary should NOT be sent (after first replacement)" 464 ); 465 466 // === Third: Load as dictionary again === 467 dump("**** Load as dictionary (second time)\n"); 468 await server.registerPathHandler("/dict/multi", function (request, response) { 469 response.writeHead(200, { 470 "Content-Type": "application/octet-stream", 471 "Use-As-Dictionary": 472 'match="/multi-match/*", id="multi-dict-2", type=raw', 473 "Cache-Control": "max-age=3600", 474 }); 475 response.end("DICTIONARY_CONTENT_V2", "binary"); 476 }); 477 478 chan = makeChan(dictUrl, true); 479 await channelOpenPromise(chan); 480 await syncCache(); 481 482 // Clear in-memory caches to force reload from disk 483 await clearDictionaryCacheAndPurgeMemory(); 484 485 chan = makeChan(matchingUrl); 486 await channelOpenPromise(chan); 487 receivedAvailableDictionary = await server.execute( 488 "global.lastMultiAvailDict" 489 ); 490 Assert.notStrictEqual( 491 receivedAvailableDictionary, 492 null, 493 "Available-Dictionary should be sent (second dictionary)" 494 ); 495 496 // === Fourth: Replace with non-dictionary again === 497 dump("**** Replace with non-dictionary again\n"); 498 await server.registerPathHandler("/dict/multi", function (request, response) { 499 response.writeHead(200, { 500 "Content-Type": "application/octet-stream", 501 "Cache-Control": "max-age=3600", 502 }); 503 response.end("NON_DICTIONARY_CONTENT_V2", "binary"); 504 }); 505 506 chan = makeChan(dictUrl, true); 507 await channelOpenPromise(chan); 508 await syncCache(); 509 await new Promise(resolve => do_timeout(200, resolve)); 510 511 await server.execute("global.lastMultiAvailDict = null"); 512 chan = makeChan(matchingUrl); 513 await channelOpenPromise(chan); 514 receivedAvailableDictionary = await server.execute( 515 "global.lastMultiAvailDict" 516 ); 517 Assert.equal( 518 receivedAvailableDictionary, 519 null, 520 "Available-Dictionary should NOT be sent (after second replacement)" 521 ); 522 523 dump("**** Test passed: Multiple replacements work correctly\n"); 524 }); 525 526 /** 527 * Test that hash mismatch during dictionary load causes the request to fail 528 * and the corrupted dictionary entry to be removed. 529 * 530 * Steps: 531 * 1. Load a resource with Use-As-Dictionary header (creates dictionary entry) 532 * 2. Verify Available-Dictionary is sent for matching resources 533 * 3. Corrupt the hash using the testing API 534 * 4. Clear memory cache to force reload from disk 535 * 5. Request a matching resource - dictionary prefetch should fail 536 * 6. Verify the dictionary entry was removed (Available-Dictionary no longer sent) 537 */ 538 add_task(async function test_dictionary_hash_mismatch() { 539 dump("**** Clear cache and start fresh\n"); 540 let lci = Services.loadContextInfo.custom(false, { 541 partitionKey: `(https,localhost)`, 542 }); 543 evict_cache_entries("all", lci); 544 545 let testingInterface = Services.cache2.QueryInterface(Ci.nsICacheTesting); 546 testingInterface.clearDictionaryCacheMemory(); 547 548 await syncCache(); 549 550 let receivedAvailableDictionary = null; 551 552 // Register dictionary endpoint 553 await server.registerPathHandler( 554 "/dict/hash-test", 555 function (request, response) { 556 response.writeHead(200, { 557 "Content-Type": "application/octet-stream", 558 "Use-As-Dictionary": 559 'match="/hash-match/*", id="hash-test-dict", type=raw', 560 "Cache-Control": "max-age=3600", 561 }); 562 response.end("DICTIONARY_FOR_HASH_TEST", "binary"); 563 } 564 ); 565 566 // Register matching resource endpoint 567 await server.registerPathHandler( 568 "/hash-match/test", 569 function (request, response) { 570 global.lastHashTestAvailDict = 571 request.headers["available-dictionary"] || null; 572 response.writeHead(200, { 573 "Content-Type": "text/plain", 574 "Cache-Control": "no-cache", 575 }); 576 response.end("HASH_MATCHING_CONTENT", "binary"); 577 } 578 ); 579 580 dump("**** Step 1: Load dictionary resource with Use-As-Dictionary\n"); 581 582 let dictUrl = `https://localhost:${server.port()}/dict/hash-test`; 583 let chan = makeChan(dictUrl); 584 let [, data] = await channelOpenPromise(chan); 585 586 Assert.equal( 587 data, 588 "DICTIONARY_FOR_HASH_TEST", 589 "Dictionary content should match" 590 ); 591 await verifyDictionaryStored(dictUrl, true); 592 await syncCache(); 593 594 dump("**** Step 2: Verify Available-Dictionary is sent\n"); 595 596 let matchingUrl = `https://localhost:${server.port()}/hash-match/test`; 597 chan = makeChan(matchingUrl); 598 await channelOpenPromise(chan); 599 600 receivedAvailableDictionary = await server.execute( 601 "global.lastHashTestAvailDict" 602 ); 603 Assert.notStrictEqual( 604 receivedAvailableDictionary, 605 null, 606 "Available-Dictionary should be sent initially" 607 ); 608 609 dump("**** Step 3: Corrupt the dictionary hash\n"); 610 611 testingInterface.corruptDictionaryHash(dictUrl); 612 613 dump("**** Step 4: Clear dictionary data to force reload from disk\n"); 614 615 // Clear dictionary data while keeping the corrupted hash. 616 // When next prefetch happens, data will be reloaded and compared 617 // against the corrupted hash, causing a mismatch. 618 testingInterface.clearDictionaryDataForTesting(dictUrl); 619 620 dump( 621 "**** Step 5: Request matching resource - should fail due to hash mismatch\n" 622 ); 623 624 await server.execute("global.lastHashTestAvailDict = null"); 625 626 // The request for the matching resource will try to prefetch the dictionary, 627 // which will fail due to hash mismatch. The channel should be cancelled. 628 chan = makeChan(matchingUrl); 629 try { 630 await channelOpenPromise(chan, true); // intermittent failure 631 } catch (e) { 632 dump(`**** Request failed with: ${e}\n`); 633 } 634 635 // Note: The request may or may not fail depending on timing. The important 636 // thing is that the dictionary entry should be removed. 637 638 dump("**** Step 6: Verify dictionary entry was removed\n"); 639 640 // Wait a bit for the removal to complete 641 await syncCache(); 642 643 await server.execute("global.lastHashTestAvailDict = null"); 644 chan = makeChan(matchingUrl); 645 await channelOpenPromise(chan); 646 647 receivedAvailableDictionary = await server.execute( 648 "global.lastHashTestAvailDict" 649 ); 650 Assert.equal( 651 receivedAvailableDictionary, 652 null, 653 "Available-Dictionary should NOT be sent after dictionary was removed due to hash mismatch" 654 ); 655 656 dump("**** Test passed: Hash mismatch properly handled\n"); 657 }); 658 659 // Cleanup 660 add_task(async function cleanup() { 661 let lci = Services.loadContextInfo.custom(false, { 662 partitionKey: `(https,localhost)`, 663 }); 664 evict_cache_entries("all", lci); 665 dump("**** All dictionary replacement tests completed\n"); 666 });