From 509e547a84c29156298040229126bf9e2f4f26c7 Mon Sep 17 00:00:00 2001 From: ppcvote Date: Sun, 23 Aug 2026 19:35:42 +0800 Subject: [PATCH 1/4] fix(search): settle bulkPut when an IndexedDB write fails TableWrapper.bulkPut ran its work inside an async Promise executor with no reject, so a rejected chunk write settled the executor's own promise rather than the returned one. The promise never settled at all. SearchService's cold-cache path waits on it behind `while (!searchServiceIsLoaded)`, so the parsing spinner stays up for good, and the .catch already written for that path in index.js could not run because nothing ever rejected. Signed-off-by: ppcvote --- .../__tests__/indexed-db-wrapper.test.js | 25 ++++++++++++ attack-search/src/indexed-db-wrapper.js | 39 +++++++++++-------- 2 files changed, 47 insertions(+), 17 deletions(-) diff --git a/attack-search/__tests__/indexed-db-wrapper.test.js b/attack-search/__tests__/indexed-db-wrapper.test.js index 51e4727b3fc..2b379212209 100644 --- a/attack-search/__tests__/indexed-db-wrapper.test.js +++ b/attack-search/__tests__/indexed-db-wrapper.test.js @@ -57,4 +57,29 @@ describe('IndexedDBWrapper', () => { const count = await contentDb.count(); expect(count).toEqual(data.length); }); + + // A failed write must settle the promise. Racing against a sentinel tells a + // rejection apart from a promise that never settles at all, which a plain + // rejects assertion cannot do: it would time out and look like a slow test. + const settle = (promise) => Promise.race([ + promise.then(() => 'resolved', (error) => `rejected:${error.message}`), + new Promise((resolve) => setTimeout(() => resolve('HUNG'), 1000)), + ]); + + test('Bulk put rejects when the underlying write fails', async () => { + jest.spyOn(contentDb.indexeddb[contentDb.tableName], 'bulkPut') + .mockRejectedValue(new Error('QuotaExceededError')); + + await expect(settle(contentDb.bulkPut(data))).resolves.toBe('rejected:QuotaExceededError'); + }); + + test('Bulk put rejects when a later chunk fails', async () => { + let calls = 0; + jest.spyOn(contentDb.indexeddb[contentDb.tableName], 'bulkPut') + .mockImplementation(() => (++calls === 2 + ? Promise.reject(new Error('DatabaseClosedError')) + : Promise.resolve())); + + await expect(settle(contentDb.bulkPut(data, 1))).resolves.toBe('rejected:DatabaseClosedError'); + }); }); diff --git a/attack-search/src/indexed-db-wrapper.js b/attack-search/src/indexed-db-wrapper.js index df24f3ff08b..9ac1400b02a 100644 --- a/attack-search/src/indexed-db-wrapper.js +++ b/attack-search/src/indexed-db-wrapper.js @@ -23,7 +23,7 @@ class TableWrapper { */ async bulkPut(data, chunkSize = 100) { - return new Promise(async (resolve) => { + return new Promise((resolve, reject) => { /** * Schedules work using requestIdleCallback if supported, or setTimeout as a fallback. * @param {Function} callback - The function to be executed when the browser is idle or after the specified delay. @@ -44,23 +44,28 @@ class TableWrapper { * @param {number} start - The index of the first item in the data array to be included in the current chunk. */ const putChunk = async (start) => { - // If all data has been processed, resolve the promise - if (start >= data.length) { - resolve(); - return; + try { + // If all data has been processed, resolve the promise + if (start >= data.length) { + resolve(); + return; + } + + // Determine the end index for the current chunk + const end = Math.min(start + chunkSize, data.length); + + // Extract the chunk from the data array + const chunk = data.slice(start, end); + + // Insert the chunk into the IndexedDB table + await this.indexeddb[this.tableName].bulkPut(chunk); + + // Schedule the next chunk to be processed + scheduleWork(() => putChunk(end)); + } catch (error) { + // Nothing else settles this promise, so callers would wait forever. + reject(error); } - - // Determine the end index for the current chunk - const end = Math.min(start + chunkSize, data.length); - - // Extract the chunk from the data array - const chunk = data.slice(start, end); - - // Insert the chunk into the IndexedDB table - await this.indexeddb[this.tableName].bulkPut(chunk); - - // Schedule the next chunk to be processed - scheduleWork(() => putChunk(end)); }; // Start processing the data array by inserting the first chunk From 7742c5cc78fdd7410c2cdd7a52d63fda96b5cb38 Mon Sep 17 00:00:00 2001 From: ppcvote Date: Mon, 24 Aug 2026 02:24:50 +0800 Subject: [PATCH 2/4] docs(changelog): note the bulkPut settle fix Signed-off-by: ppcvote --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2039c128b43..d34f790ff57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Website Changelog +## Unreleased + +### Bug Fixes + +* Settle the search index write when an IndexedDB write fails, instead of leaving the promise pending and the search spinner up. + ## v5.0.0 (2026-08-06) * Release ATT&CK content version 19.2. From 062f47721098e23a43cc345528a66996ad6e17f6 Mon Sep 17 00:00:00 2001 From: ppcvote Date: Sat, 5 Sep 2026 04:33:38 +0800 Subject: [PATCH 3/4] fix(search): surface a failed index build instead of spinning forever A failed cold start left searchServiceIsLoaded false, and `search` waits on that flag in a loop, so every query after the failure re-showed the parsing icon every 100ms for as long as the page stayed open. The warm restore path had the opposite problem: its `finally` set the flag to true unconditionally, overriding the false its own `catch` had just set, so a failed restore reported success and then queried a SearchService that never initialized. Both paths now put the controls into the same unavailable state the unsupported browser branch already used, which that branch now shares, and `search` stops waiting once the index is known not to be coming. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + attack-search/__tests__/search-events.test.js | 45 +++++++++++++++++++ attack-search/src/index.js | 38 +++++++++++----- 3 files changed, 74 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d34f790ff57..aa3d3086648 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Bug Fixes * Settle the search index write when an IndexedDB write fails, instead of leaving the promise pending and the search spinner up. +* Disable the search controls and explain why when the search index cannot be built, instead of leaving the spinner running for as long as the page is open. ## v5.0.0 (2026-08-06) diff --git a/attack-search/__tests__/search-events.test.js b/attack-search/__tests__/search-events.test.js index 86af5fc0e19..745e06df3b6 100644 --- a/attack-search/__tests__/search-events.test.js +++ b/attack-search/__tests__/search-events.test.js @@ -112,8 +112,53 @@ describe('search event bindings', () => { expect(mockJqueryApis['[data-search-filter-dropdown="core"]'].attr) .toHaveBeenCalledWith('aria-hidden', 'false'); }); + + test('a failed index build stops search from waiting for an index that never arrives', async () => { + await loadIndexWithAFailingColdStart(); + + const parsingIcon = mockJqueryApis['#search-parsing-icon']; + parsingIcon.show.mockClear(); + parsingIcon.hide.mockClear(); + + handlerForSelector('#search-input')({ target: { value: 'mimikatz' } }); + + // Before the fix `search` looped on the loaded flag, so it showed the parsing icon on + // its first pass and kept doing so every 100ms for as long as the page stayed open. + expect(parsingIcon.show).not.toHaveBeenCalled(); + expect(parsingIcon.hide).toHaveBeenCalled(); + }); + + test('a failed index build puts the search controls into their unavailable state', async () => { + await loadIndexWithAFailingColdStart(); + + expect(mockJqueryApis['#search-input'].prop).toHaveBeenCalledWith('disabled', true); + expect(mockJqueryApis['#search-button'].prop).toHaveBeenCalledWith('disabled', true); + expect(mockJqueryApis['#search-icon'].removeClass).toHaveBeenCalledWith('search-icon'); + expect(mockJqueryApis['#search-icon'].addClass).toHaveBeenCalledWith('error-icon'); + expect(mockJqueryApis['#search-button'].prop) + .toHaveBeenCalledWith('title', expect.stringContaining('search index could not be built')); + }); }); +// Load the module on the cold-start path with the document fetch failing, and run the +// debouncer straight through so the input handler reaches `search` without a timer. +async function loadIndexWithAFailingColdStart() { + global.window = { indexedDB: {} }; + global.localStorage.getItem.mockReturnValue(null); + + jest.doMock('../src/search-loader.js', () => ({ + loadSearchDocuments: () => Promise.reject(new Error('documents unavailable')), + })); + jest.doMock('../src/debouncer.js', () => class { + debounce(callback) { + callback(); + } + }); + + require('../src/index'); + await new Promise(resolve => setImmediate(resolve)); +} + function eventsForSelector(selector) { return mockJqueryCalls .filter(call => call.selector === selector || call.delegatedSelector === selector) diff --git a/attack-search/src/index.js b/attack-search/src/index.js index d3aa49f3b30..5ee5c82bbf0 100644 --- a/attack-search/src/index.js +++ b/attack-search/src/index.js @@ -89,6 +89,23 @@ const closeSearch = function () { // Variable to check if search service is loaded let searchServiceIsLoaded = false; +// Set once the index cannot be built at all. Without it `search` waits for a flag that is +// never going to flip and the parsing spinner runs for as long as the page is open. +let searchServiceUnavailable = false; + +// Put the search controls into their unavailable state and explain why on hover. +function markSearchUnavailable(reason) { + searchServiceUnavailable = true; + searchServiceIsLoaded = false; + searchInput.prop('disabled', true); + searchButton.prop('disabled', true); + searchIcon.removeClass('search-icon'); + searchIcon.addClass('error-icon'); + searchButton.prop('title', reason); +} + +const SEARCH_INDEX_FAILED_MESSAGE = 'The search index could not be built. Reload the page to try again.'; + // Initialize the search service async function initializeSearchService() { console.debug('Initializing search service...'); @@ -111,12 +128,12 @@ async function initializeSearchService() { await searchService.initializeAsync(null); // Passing null will instruct the search service to attempt // restoring itself from the IndexedDB console.debug('SearchService is initialized.'); + searchServiceIsLoaded = true; } catch (error) { console.error('Failed to initialize SearchService:', error); - searchServiceIsLoaded = false; + markSearchUnavailable(SEARCH_INDEX_FAILED_MESSAGE); } finally { searchParsingIcon.hide(); - searchServiceIsLoaded = true; } } else { @@ -139,18 +156,14 @@ async function initializeSearchService() { .catch(error => { console.error('Failed to initialize SearchService:', error); searchParsingIcon.hide(); - searchServiceIsLoaded = false; + markSearchUnavailable(SEARCH_INDEX_FAILED_MESSAGE); }); } } else { // Disable the search button and display an error icon with a hover effect that displays a message/explanation console.error('Search is only available in browsers that support IndexedDB. Please try using Firefox, Chrome, Safari, or another browser that supports IndexedDB.'); - searchInput.prop('disabled', true); - searchButton.prop('disabled', true); - searchIcon.removeClass('search-icon'); - searchIcon.addClass('error-icon'); - searchButton.prop('title', 'To use the search feature, please make sure your browser supports IndexedDB. If not, consider upgrading your browser or switching to a supported browser such as Firefox, Chrome, or Safari.') + markSearchUnavailable('To use the search feature, please make sure your browser supports IndexedDB. If not, consider upgrading your browser or switching to a supported browser such as Firefox, Chrome, or Safari.'); } } @@ -158,13 +171,18 @@ async function initializeSearchService() { const search = async function (query) { console.debug(`search -> Received search query: ${query}`); - // Wait until the search service is loaded - while (!searchServiceIsLoaded) { + // Wait until the search service is loaded, or until we know it never will be. + while (!searchServiceIsLoaded && !searchServiceUnavailable) { console.debug('search -> search index is not loaded...'); searchParsingIcon.show(); await new Promise(resolve => setTimeout(resolve, 100)); } + if (searchServiceUnavailable) { + searchParsingIcon.hide(); + return; + } + console.debug(`Executing search: ${query}`); await searchService.query(query); searchParsingIcon.hide(); From 0d58ced80e37c78c0caf6dbd18e4d55906b58c9a Mon Sep 17 00:00:00 2001 From: ppcvote Date: Sat, 5 Sep 2026 04:46:16 +0800 Subject: [PATCH 4/4] test(search): cover the warm restore path as well as the cold start The two tests added with the fix both drive the cold-start branch. This adds one for the cached branch, where the `finally` used to override the `catch`, so both halves of the change have a test that fails without it. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 +- attack-search/__tests__/search-events.test.js | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa3d3086648..57b7b2c46aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Bug Fixes * Settle the search index write when an IndexedDB write fails, instead of leaving the promise pending and the search spinner up. -* Disable the search controls and explain why when the search index cannot be built, instead of leaving the spinner running for as long as the page is open. +* Disable the search controls and explain why when the search index cannot be built, instead of leaving the spinner running for as long as the page is open. A failed restore from the cached index is no longer reported as a successful load. ## v5.0.0 (2026-08-06) diff --git a/attack-search/__tests__/search-events.test.js b/attack-search/__tests__/search-events.test.js index 745e06df3b6..5c27afdf8a5 100644 --- a/attack-search/__tests__/search-events.test.js +++ b/attack-search/__tests__/search-events.test.js @@ -128,6 +128,15 @@ describe('search event bindings', () => { expect(parsingIcon.hide).toHaveBeenCalled(); }); + test('a failed restore from the cache is not reported as a successful load', async () => { + await loadIndexWithAFailingWarmRestore(); + + // The catch used to set the loaded flag false and the finally set it straight back to + // true, so `search` went on to query an index that was never populated. + expect(mockJqueryApis['#search-input'].prop).toHaveBeenCalledWith('disabled', true); + expect(mockJqueryApis['#search-icon'].addClass).toHaveBeenCalledWith('error-icon'); + }); + test('a failed index build puts the search controls into their unavailable state', async () => { await loadIndexWithAFailingColdStart(); @@ -159,6 +168,24 @@ async function loadIndexWithAFailingColdStart() { await new Promise(resolve => setImmediate(resolve)); } +// Load the module on the cached path, with restoring the index from IndexedDB failing. +async function loadIndexWithAFailingWarmRestore() { + const { searchCacheCompatibilityVersion, searchCacheSchemaVersion } = require('../src/settings'); + const version = `${searchCacheSchemaVersion}-${searchCacheCompatibilityVersion}`; + + global.window = { indexedDB: {} }; + global.localStorage.getItem.mockReturnValue(`${global.build_uuid}-search-${version}`); + + jest.doMock('../src/search-service.js', () => class { + initializeAsync() { + return Promise.reject(new Error('cached index is unreadable')); + } + }); + + require('../src/index'); + await new Promise(resolve => setImmediate(resolve)); +} + function eventsForSelector(selector) { return mockJqueryCalls .filter(call => call.selector === selector || call.delegatedSelector === selector)