JavaScriptPromiseIndexedDBESLint開源TestingMITRE ATT&CK

A Promise That Never Settles: How a Failed IndexedDB Write Left the MITRE ATT&CK Search Spinning

· 19 min read
Table of Contents
  1. What attack-website is, and what it is not
  2. The code
  3. Why it hangs instead of failing
  4. Would dropping `async` have fixed it?
  5. The fix
  6. What can actually fail on a cold cache
  7. How do you test for a hang?
  8. The maintainer: it rejects now, and the user is still watching a spinner
  9. The third layer, which he wrote himself
  10. Process facts: 22 days, zero formal reviews, no CI, not yet deployed
  11. The AI disclosure that was not written
  12. What to take away

When a write fails inside new Promise(async (resolve) => ...), the promise handed to the caller is not rejected. It never settles at all: nothing calls resolve, nothing calls reject, and whatever awaited it waits forever. The search index build on the MITRE ATT&CK website (attack.mitre.org) awaited exactly that, so a failed IndexedDB chunk write left the .catch the authors had already written with nothing to catch, and the loading spinner ran for as long as the page stayed open. The fix is mitre-attack/attack-website#637, merged into the develop branch on September 14, 2026.

Three things worth the space: why "never settles" and "rejects" are different failures, why clearing the ESLint rule would not have fixed it, and how to write a test that can tell a hang apart from a rejection.

What attack-website is, and what it is not

attack-website is the Apache-2.0 source code that generates the MITRE ATT&CK website. Its README puts it plainly:

This repository contains the source code used to generate the MITRE ATT&CK website as seen at attack.mitre.org. The source code is flexible to allow users to generate the site with custom content.

So: the source that produces that site, flexible enough that others can generate a site with their own content. As of September 16, 2026 it shows 588 stars and 174 forks. That is the whole of what I will claim about it. The browser-side search lives in attack-search/, a separate CommonJS subproject: FlexSearch builds the index, Dexie over IndexedDB holds the cache (the lockfile resolves flexsearch 0.8.212 and dexie 3.2.7).

The code

TableWrapper.bulkPut writes data into IndexedDB in chunks. Reduced to its shape, with line numbers pointing at the original file:

// attack-search/src/indexed-db-wrapper.js
async bulkPut(data, chunkSize = 100) {
  return new Promise(async (resolve) => {        // line 26: the executor took only resolve
    const putChunk = async (start) => {
      if (start >= data.length) {
        resolve();                               // reached only after every chunk lands
        return;
      }
      const chunk = data.slice(start, start + chunkSize);
      await this.indexeddb[this.tableName].bulkPut(chunk);   // line 60: unguarded
      this.scheduleWork(() => putChunk(start + chunkSize));
    };
    putChunk(0);                                 // called, never awaited
  });
}

resolve() sits on one path only, the one where start >= data.length. If any chunk fails, that path is never reached, and there is no reject in scope to call instead. The result is not an error. It is stillness.

Why it hangs instead of failing

MDN's Promise() constructor page states two things that matter here: "The executor return value is ignored", and "If an error is thrown in the executor, the promise is rejected, unless resolveFunc or rejectFunc has already been called." The second sentence sounds like a rescue, but it covers an error thrown synchronously in the executor body. There is none here.

Two promises exist in this code. One is built by new Promise(...) and handed to the caller. The other is produced by calling the async function putChunk(0). putChunk is never awaited, so its promise is discarded. When the write fails, the rejection settles the discarded one, which surfaces as an unhandled rejection in Node and as an unhandledrejection in the browser console. The promise the caller is holding is never touched by anything.

I wrote each shape as a small file and ran them on Node 22.22.0:

Shape What the caller gets
A: this repository's shape (async executor, putChunk(0) not awaited) PENDING, plus an unhandled rejection
B: async executor that does await the failing work PENDING, plus an unhandled rejection
C: the fix (try/catch calling reject) REJECTED

A and B are indistinguishable from the caller's side. The only difference is whose promise got thrown away. That distinction is worth stating because I got it loose in my own PR description: I wrote that the rejection settled the executor's own throwaway promise. For this code that is wrong. putChunk(0) is never awaited, so the discarded promise is putChunk's. Both variants leave the caller pending forever, which is why the loose wording caused no harm, but a post explaining the semantics has to get it right, because anyone who opens the diff will see it.

Would dropping `async` have fixed it?

No. attack-search/.eslintrc extends airbnb-base, which sets no-async-promise-executor to error in rules/errors.js. The rule is part of eslint:recommended, and its documentation gives the rationale:

If an async executor function throws an error, the error will be lost and won't cause the newly-constructed Promise to reject.

Running npx eslint src (ESLint v8.57.1) in that subproject before the fix reported that rule at indexed-db-wrapper.js line 26, column 28. After the fix, zero. It looks like lint was pointing at the culprit all along.

But "lint pointed at the right line" and "lint's reason is your bug's reason" are different claims. I ran a fourth shape: a non-async executor, everything else unchanged, putChunk(0) still un-awaited and still unguarded. Result: PENDING plus an unhandled rejection, exactly as before. And a fifth: a non-async executor with a try/catch that calls reject, with the failure pushed into a later chunk scheduled by setTimeout. Result: a clean REJECTED.

So the reject inside the try/catch is what fixes it, and the clean lint report is a side effect. Worth adding: no CI workflow in this repository runs ESLint at all, so that error was never going to block anyone before merge.

The fix

return new Promise((resolve, reject) => {
  const putChunk = async (start) => {
    // ...
    try {
      await this.indexeddb[this.tableName].bulkPut(chunk);
    } catch (error) {
      reject(error);
      return;
    }
    this.scheduleWork(() => putChunk(start + chunkSize));
  };
  putChunk(0);
});

The shape is not an invention. backupSearchIndex in search-service.js already used new Promise((resolve, reject) => ...) with a rejectOnce helper to guarantee a single rejection. Same codebase, a few dozen lines apart, one took reject and one did not.

What can actually fail on a cold cache

bulkPut has exactly one call site in the source: search-service.js line 175, await this.contentDb.bulkPut(searchableDocuments, 100), on the branch taken when documents were just fetched, which is the cold-cache path. index.js then spins on while (!searchServiceIsLoaded) at line 162 while showing the parsing icon, and the .catch for that path sits at line 139 of the same file. The .catch exists. Nothing ever arrives at it.

Concretely, the cold-cache path fetches 14 JSON files from /search/ (campaigns, assets, datacomponents, groups, matrices, misc, mitigations, resources, software, sub-techniques, tactics, techniques, detectionstrategies, analytics), concatenates them, builds an in-memory FlexSearch Document index over title and content, exports that index to IndexedDB, then writes the documents themselves through bulkPut in chunks of 100.

Measured against the live site on September 16, 2026, and only on the cold-cache path: those 14 files total 20,587,978 bytes uncompressed, about 20.6 MB, and hold 3,413 documents. They are compressed in transit, for example techniques.json is 4,234,161 bytes raw and 1,403,357 bytes gzipped. The document count came from Python's JSON parser rather than a regex, and analytics.json is a single 663 KB document, so 3,413 is a genuine document count and not an artefact of counting files. At a chunk size of 100 that is 35 chunked writes, any one of which can drop into the hole above.

How those writes fail is documented by the APIs, not observed by me. Dexie's docs say bulkPut signals failure by rejection ("If some operations fail, bulkPut() will ignore those failures and return a rejected Promise with a Dexie.BulkError referencing the failures"), and DatabaseClosedError covers a connection closed explicitly or never opened. MDN's storage quotas page says that "Attempting to store more than an origin's quota using IndexedDB, Cache, or OPFS, for example, fails with a QuotaExceededError exception" and advises that "Web developers should wrap JavaScript that writes to browser storage within try...catch blocks."

To be explicit: I have no evidence anyone hit this. No issue, no telemetry, no user report. Under the PR template's related-issues heading I wrote that I could find none open, because the failure is silent. This is a latent failure mode, not an outage. It is the same disease as the ones in our own infrastructure where the service was alive and doing nothing, moved down into a single promise in a browser.

How do you test for a hang?

This is the kind of bug that invites a test which looks reasonable and proves nothing. Write await expect(contentDb.bulkPut(data)).rejects.toThrow() and the unpatched code will indeed fail it, but the report will not tell you why: the assertion just times out, and a timeout looks exactly like a slow test. "Hung" and "rejected" have to become two different strings you can print.

So the new tests race the call against a sentinel:

const settle = (promise) => Promise.race([
  promise.then(() => 'resolved', (error) => `rejected:${error.message}`),
  new Promise((resolve) => setTimeout(() => resolve('HUNG'), 1000)),
]);

The assertion is on that string. I restored the unpatched indexed-db-wrapper.js and ran them: Expected "rejected:QuotaExceededError" / Received "HUNG", and Expected "rejected:DatabaseClosedError" / Received "HUNG". Those two error names are test doubles drawn from the documented failure modes above, not things I watched happen in production.

The second test deliberately pushes the failure into a later chunk: chunkSize of 1, with the mock rejecting only on the second call. The reason is that chunks do not all run in the same tick. scheduleWork uses requestIdleCallback where available and falls back to setTimeout(callback, 10) otherwise, per a comment in the file dated April 2023 naming Safari. A first-chunk failure is still on the original call stack; a later chunk is in a different scheduling turn. They deserve separate tests.

The three tests on the index.js side behave the same way: restore the unpatched index.js and all three named tests go red. One detail tells the whole story. That run needed --forceExit, because the old search loop never terminates. The test process was caught by the same bug the test was about.

Test counts only mean something with a baseline. The fork point 55660a5 had 42 passing. Commit 509e547, the bulkPut fix plus two wrapper tests, had 44. My branch head 0d58ced, plus three more tests, had 47. The merge commit 8fc68d0 has 68, because develop itself grew tests during the 22 days. Run the suite today and you will see 68, not 47.

The maintainer: it rejects now, and the user is still watching a spinner

The PR opened on August 23, 2026 at 11:39:40Z. The first maintainer response came 12 days, 6 hours and 41 minutes later:

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!

He accepted the diagnosis, confirmed for himself that the promise now rejects, and then rejected the remedy as incomplete. My change made the failure reportable. His point was that the failure was still invisible. From where the user sits, nothing had changed.

The second round landed within 2 hours 13 minutes of his comment, with the reply at 2 hours 26 minutes. It added a searchServiceUnavailable flag, a shared markSearchUnavailable() that the unsupported-browser branch now also routes through, and a loop condition that stops waiting once the index is known not to be coming.

Writing that turned up the opposite defect on the warm restore path: its finally block set searchServiceIsLoaded = true unconditionally, overriding the false its own catch had just set. So a failed restore was reported as a successful load. One path treated a failure as never finishing; the other treated it as finished.

The third layer, which he wrote himself

Then 9 days, 23 hours and 27 minutes of silence. On September 14, 2026 at 20:14:00Z he pushed his own commit ffd0522, commented at 20:17:11Z, and merged at 20:17:19Z, three minutes and nineteen seconds after his own commit.

I pushed ffd0522 to address an additional recovery issue - failed cached restores now clear the cache marker and delete the unusable IndexedDB database, allowing the next reload to rebuild the index instead of repeating the same failure. I'll go ahead and merge this in now!

The commit, "fix(search): implement cache invalidation for failed search index builds", is +30 / -2 across three files. It adds an invalidateSearchCache() that removes the localStorage marker and calls searchService.db.indexeddb.delete(), each wrapped in its own try/catch, with a comment explaining the constraint:

Cache cleanup is best-effort and must not mask the original initialization failure or prevent the unavailable UI state from being shown.

He called it from the warm-restore catch block, extended my warm-restore test to assert that localStorage.removeItem(cacheKey) runs and that the database delete is called once, and rewrote the CHANGELOG bullet to read "A failed restore from the cached index is discarded instead of being reported as a successful load or retried after every reload."

That cache marker is a localStorage key of the form saved_uuid_search_schema_4-flexsearch-0.8.212, built from searchCacheSchemaVersion = 4 and the flexsearch version in the package; the deployed bundle uses the same scheme.

Flattened out, the exchange is three layers. My fix made the failure reportable. His review pointed out it was still not visible. My second round made it visible. His own commit made it recoverable. And the honest part: I supplied the first two only after being told the first was not enough.

Process facts: 22 days, zero formal reviews, no CI, not yet deployed

  • Opened to merged: 22 days, 8 hours, 37 minutes, 39 seconds (536.63 hours).
  • Formal GitHub reviews: zero. The reviews array is empty and the review-comments endpoint returns []. All feedback arrived as ordinary issue comments, three in total, two from the maintainer and one from me.
  • On August 26, 2026 at 19:43:01Z, 3 days and 8 hours after the PR opened, jondricek requested a review from adpare and assigned the PR to adpare. adpare never reviewed or commented.
  • It was merged by jondricek (Jared Ondricek). What is verifiable: he merged it, he pushed a commit to it, his GitHub profile lists the company "The MITRE Corporation", that commit is authored from jondricek@mitre.org, and GitHub labels his comments authorAssociation: CONTRIBUTOR. I am giving him no title beyond that, because these sources do not support one.
  • The merge is a real merge commit (8fc68d0, two parents), neither squashed nor rebased.
  • CI: gh pr checks 637 reports no checks on the branch at all. No workflow in the repository runs Jest or ESLint. The only PR-triggered workflow is a SonarCloud scan; the other builds and deploys GitHub Pages on push to master. That is not my inference, the repository says it in AGENTS.md: "CI clearly builds the site and search bundle, but does not currently enforce Jest, ESLint, Stylelint, Ruff, or type checks."

One more pair of facts I find interesting and will not connect. The PR template GitHub served me on August 23 dated from 2019, and its second item read "Assign and/or mention a reviewer (typically @isaisabel)". I followed it: 6 hours and 50 minutes after opening, I edited the body to add the template's headings, the CHANGELOG paragraph, and the line "@isaisabel for review, per the template." isaisabel never appeared on the thread. Three days later, on August 26, jondricek replaced the PR template on develop with a new four-heading version. Both things happened. Nothing anywhere links them.

Last, deployment. The merge went into develop, and the GitHub Pages workflow deploys only on push to master, whose newest commit is from August 7, 2026. I checked the deployed artefact directly: I downloaded attack.mitre.org's search_bundle.js on September 16, 2026 (447,975 bytes) and searched it with fixed strings rather than regex. The two user-visible strings the fix introduces appear zero times, while the controls saved_uuid_search_schema_, requestIdleCallback and error-icon all appear, which proves the check can find a string that is present. So: merged, and not in the deployed bundle at the time of writing. AGENTS.md warns about exactly this trap: "Do not assume master is the integration branch just because GitHub Pages deploys from it."

The AI disclosure that was not written

In the chainladder write-up, casact had an AI usage policy and I filed an explicit disclosure in the PR. This is the inverse case, and saying so accurately is the point.

This repository had no AI or AI-disclosure policy at the time, and still has none. docs/CONTRIBUTING.md contains two things: target develop, and agree to the Developer's Certificate of Origin v1.1. There is no code of conduct, no security policy, and no organisation-level .github repository (the API returns 404), so there are no org-wide community health files either. The repository does carry an AGENTS.md, a 200-line guide written by the maintainer for coding agents working in the repo, covering build commands, style, validation and workflow expectations. It contains no disclosure requirement.

My PR said nothing about AI, in either the original body or the edited one, and neither did any of my three comments. The only trace is in the git record: the second-round commits, 062f477 and 0d58ced, end with a Co-Authored-By: Claude Opus 5 (1M context) trailer. The first two, 509e547 and 7742c5c, carry no trailer and end with Signed-off-by: ppcvote instead.

So the accurate statement is: there was no policy to comply with, no disclosure was written in the PR, and a machine-readable co-author trailer on two of four commits is the whole of it. While I am being exact, those same two commits lack the Signed-off-by line that the DCO section of CONTRIBUTING.md asks for. The maintainer never raised it. That does not make it right.

What to take away

  1. "Never settles" and "rejects" are different failures, and only the second one reaches your error handling. The question to ask about a failure path is not "is there a catch" but "is anything guaranteed to settle this promise". The .catch in this codebase was already written, on line 139. It just never received anything.
  2. Lint pointing at the right line does not mean lint's reason is your bug's reason. no-async-promise-executor flagged line 26, but removing async does not settle the promise, as Node 22 confirms in three lines of reproduction. Treat it as a lead, not a conclusion.
  3. To test for a hang, make the hang a printable value. Racing a sentinel turns a timeout into the string HUNG, so the report reads Expected rejected / Received HUNG instead of a red timeout that could mean anything.
  4. Reportable, visible and recoverable are three separate properties. I delivered the first, was told it was not enough, delivered the second, and the maintainer wrote the third himself. Before fixing a failure path, ask about all three separately.

The PR: mitre-attack/attack-website#637, +182 / -27 across five files, of which my four commits are +154 / -27 and the maintainer's is +30 / -2. For another bug in the same family, where a language rule quietly contradicts intuition, see a named parameter never reaches **kwargs; more shapes of the same family are in the seven-PR roundup.

FAQ

Why does my JavaScript promise never resolve or reject?

Because nothing inside the executor ever calls resolve or reject. A promise built with new Promise(executor) is settled only by those two functions, and the executor's own return value is ignored. If the failure happens inside an async executor, or inside a function the executor called without awaiting it, the rejection settles some other promise that nobody is holding. The caller does not get an error, it gets silence.

What does the ESLint rule no-async-promise-executor protect against, and does clearing it fix the bug?

The rule is part of eslint:recommended and its documented rationale is that if an async executor function throws an error, the error will be lost and will not cause the newly-constructed Promise to reject. But removing async on its own does not settle the promise. Verified in Node 22: a non-async executor whose un-awaited async work rejects still leaves the caller pending forever and still produces an unhandled rejection. The try/catch that calls reject is the actual fix; a clean lint report is a side effect.

How do you write a test that proves a promise hangs instead of rejecting?

Race it against a sentinel. Use Promise.race between the call under test, mapped to the string resolved or to rejected plus the error message, and a setTimeout that resolves to the string HUNG after one second, then assert on the resulting string. A plain expect(...).rejects assertion cannot tell the two apart: it simply times out and reads as a slow test in the report.

What happens when an IndexedDB write exceeds the browser's storage quota?

MDN states that attempting to store more than an origin's quota using IndexedDB, Cache, or OPFS fails with a QuotaExceededError exception, and advises wrapping JavaScript that writes to browser storage in try...catch blocks. MDN defines QuotaExceededError as an error raised when a requested operation would exceed a system-imposed storage quota; it is a subclass of DOMException. With Dexie, bulkPut returns a rejected promise carrying a Dexie.BulkError. Whether the caller ever sees that rejection depends on whether anything is holding the promise.

Is the MITRE ATT&CK search fix live on attack.mitre.org?

Not as of September 16, 2026. PR #637 was merged into the develop branch on September 14, 2026, and the repository's GitHub Pages workflow deploys only on push to master, whose newest commit dates from August 7, 2026. A fixed-string search of the deployed search_bundle.js finds none of the strings the fix adds.

Weekly AI Automation Playbook

No fluff — just templates, SOPs, and technical breakdowns you can use right away.

Join the Solo Lab Community

Free resource packs, daily build logs, and AI agents you can talk to. A community for solo devs who build with AI.

Need Technical Help?

Free consultation — reply within 24 hours.