Skip to content

fix(search): bulkPut hangs instead of rejecting when an IndexedDB write fails - #637

Open
ppcvote wants to merge 4 commits into
mitre-attack:developfrom
ppcvote:fix/bulkput-settle
Open

fix(search): bulkPut hangs instead of rejecting when an IndexedDB write fails#637
ppcvote wants to merge 4 commits into
mitre-attack:developfrom
ppcvote:fix/bulkput-settle

Conversation

@ppcvote

@ppcvote ppcvote commented Aug 23, 2026

Copy link
Copy Markdown

Description of what has changed

TableWrapper.bulkPut ran its work inside new Promise(async (resolve) => ...) with no reject, and the chunk write at indexed-db-wrapper.js:60 was unguarded. When Dexie rejects there (QuotaExceededError, DatabaseClosedError, an aborted transaction) the rejection settled the executor's own throwaway promise, so the promise returned to the caller never settled at all. It hung rather than rejected.

That matters because SearchService.initializeAsync awaits it on the cold-cache path, and index.js:162 then spins on while (!searchServiceIsLoaded) with the parsing spinner shown. The .catch already written for that path at index.js:139 could not run, because nothing ever rejected.

The fix takes reject and wraps the chunk loop in try/catch, following the shape already used in backupSearchIndex. Dropping async from the executor also clears no-async-promise-executor, which this repo's eslint config sets to error: one problem at line 26 before, none after.

Two tests added to the existing file. They race the call against a 1s sentinel so a hang is distinguishable from a rejection; a plain rejects assertion would just time out and read as a slow test. Both report Received: "HUNG" before the change and pass after. Full suite: 44 passing.

CHANGELOG.md has an Unreleased section for this. There was no pending heading, so please fold it into whichever version you cut next.

Issues addressed by pull request

None open that I could find; the failure is silent, so it would surface as "search never finishes loading" rather than an error.

@isaisabel for review, per the template.

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 <risky9763@gmail.com>
Signed-off-by: ppcvote <risky9763@gmail.com>
@jondricek

Copy link
Copy Markdown
Contributor

Thanks! The underlying bulkPut diagnosis is definitely an issue, and I was able to confirm that the promise now rejects instead of hanging. But a cold cache catch leaves searchServiceIsLoaded false, while search() loops until it becomes true, so an affected user still gets an endless spinner.

If you are able to tackle that, great! If not, then I can get to work on it eventually. I appreciate the contribution though!

ppcvote and others added 2 commits September 5, 2026 04:33
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@ppcvote

ppcvote commented Sep 4, 2026

Copy link
Copy Markdown
Author

Took it. Pushed 062f477 and 0d58ced.

You were right about the cold path. The catch left searchServiceIsLoaded false and search loops on that flag, 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 search went on to query an index that was never populated. SearchService builds its AttackIndex in the constructor, so there is nothing to throw on, and the user gets an empty result set rather than an error.

Both paths now call one markSearchUnavailable, which is the treatment the unsupported-browser branch already applied, so that branch shares it instead of repeating it. search stops waiting once the index is known not to be coming.

Three tests in search-events.test.js: two drive the cold-start branch with a failing document load, one drives the cached branch with a failing restore. They assert the parsing icon is not shown again after the failure, and that the controls end up disabled with the error icon. All three fail on the previous index.js and pass on this one; full suite is 47 passed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants