永遠不會 settle 的 Promise:IndexedDB 寫入失敗讓 MITRE ATT&CK 網站搜尋一直轉圈圈
目錄
在 new Promise(async (resolve) => ...) 裡面寫入失敗時,交給呼叫端的那個 promise 不會被拒絕,它永遠不會 settle:沒有人呼叫 resolve,也沒有人呼叫 reject,於是 await 它的程式碼永遠等下去。MITRE ATT&CK 網站(attack.mitre.org)的搜尋索引建立流程就 await 在這樣一個 promise 上,IndexedDB 的分塊寫入一旦失敗,作者早就寫好的 .catch 永遠不會被觸發,搜尋的載入圖示會一直轉到使用者關掉分頁為止。這筆修正是 mitre-attack/attack-website#637,2026 年 9 月 14 日合併進 develop 分支。
這篇把三件事講完:為什麼「不 settle」和「reject」是兩種不同的失敗、為什麼把 ESLint 那條規則清乾淨並不會修好它、以及怎麼寫一個分得出「卡住」和「被拒絕」的測試。
這個 repo 是什麼
attack-website 是 Apache-2.0 授權的開源程式碼,用來產生 attack.mitre.org 這個網站。README 寫得很直接:
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.
也就是產生那個網站的原始碼,而且彈性到可以讓別人拿去產生帶自訂內容的站。2026 年 9 月 16 日查到 588 顆星、174 個 fork。我只宣稱這些,不宣稱誰在用它。瀏覽器端的搜尋放在 attack-search/ 這個獨立的 CommonJS 子專案:索引用 FlexSearch,快取走 Dexie 包在 IndexedDB 上(lockfile 裡是 flexsearch 0.8.212、dexie 3.2.7)。
那段程式碼
TableWrapper.bulkPut 把資料切塊寫進 IndexedDB。縮成最小形狀是這樣,原始檔還有表格名稱與排程細節,行號標的是原始檔的位置:
// attack-search/src/indexed-db-wrapper.js
async bulkPut(data, chunkSize = 100) {
return new Promise(async (resolve) => { // 第 26 行:executor 只拿了 resolve
const putChunk = async (start) => {
if (start >= data.length) {
resolve(); // 只有全部分塊都寫完才會走到
return;
}
const chunk = data.slice(start, start + chunkSize);
await this.indexeddb[this.tableName].bulkPut(chunk); // 第 60 行:沒有保護
this.scheduleWork(() => putChunk(start + chunkSize));
};
putChunk(0); // 有呼叫,但沒有 await
});
}
resolve() 只在 start >= data.length 那一條路上。任何一塊寫失敗,那一條路就走不到,而 executor 裡沒有 reject 可以呼叫。結果不是錯誤,是靜止。
為什麼是「永遠不結束」而不是「失敗」
MDN 的 Promise() 建構子頁面講了兩件關鍵的事:「The executor return value is ignored」,以及「If an error is thrown in the executor, the promise is rejected, unless resolveFunc or rejectFunc has already been called」。第二句聽起來像是有救的,但它說的是同步拋在 executor 本體裡的錯誤。這裡沒有。
這段程式碼裡同時存在兩個 promise:new Promise(...) 建出來、回傳給呼叫端的那一個,以及 putChunk(0) 這個 async 函式呼叫自己產生的那一個。putChunk 沒有被 await,它的 promise 被丟掉。寫入失敗時,拒絕落在被丟掉的那一個身上,在 Node 裡表現成 unhandled rejection,在瀏覽器裡表現成主控台的一則 unhandledrejection。呼叫端手上那個,從頭到尾沒有任何人碰過。
我在 Node 22.22.0 上把幾種形狀各寫成一支小檔案跑過:
| 形狀 | 呼叫端拿到的狀態 |
|---|---|
A:這個 repo 的形狀(async executor,putChunk(0) 沒有 await) |
PENDING,另外冒一則 unhandled rejection |
| B:async executor,而且 await 了會失敗的工作 | PENDING,另外冒一則 unhandled rejection |
| C:修好的形狀(try/catch 裡呼叫 reject) | REJECTED |
A 和 B 對呼叫端來說一模一樣,差別只在「被丟掉的是誰的 promise」。這件事值得說,因為我在自己的 PR 描述裡把它寫鬆了:我寫的是拒絕 settle 了 executor 自己那個用完就丟的 promise。以這段程式碼而言那是錯的,putChunk(0) 沒有被 await,被丟掉的是 putChunk 的 promise。兩種情況呼叫端都一樣永遠等下去,所以那句話沒有造成傷害,但一篇在解釋語意的文章不能跟著錯。
拿掉 async 不會修好它
attack-search/.eslintrc 繼承 airbnb-base,而 airbnb-base 的 rules/errors.js 把 no-async-promise-executor 設成 error。這條規則屬於 eslint:recommended,官方文件給的理由是:
If an async executor function throws an error, the error will be lost and won't cause the newly-constructed Promise to reject.
修正前在這個子專案跑 npx eslint src(ESLint v8.57.1),這條規則剛好就報在 indexed-db-wrapper.js 第 26 行第 28 欄;修正後是零。看起來像是 lint 早就指著兇手了。
但「lint 指的地方對」跟「lint 的理由就是你的 bug 的理由」是兩件事。我把第四種形狀也跑了一次:executor 不是 async,其餘完全不變,putChunk(0) 一樣沒有 await、一樣沒有 try/catch。結果還是 PENDING 加一則 unhandled rejection。反過來,第五種形狀:非 async 的 executor,加上 try/catch 呼叫 reject,並且讓失敗發生在後面用 setTimeout 排進去的分塊裡,結果是乾淨的 REJECTED。
所以真正修好它的是 try/catch 裡那個 reject,lint 變乾淨是順帶的。而且要講清楚:這個 repo 的 CI 從來沒有跑過 ESLint,這條規則的錯誤在合併之前不會擋任何人。
修法
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);
});
這個形狀不是我發明的,同一個 repo 裡早就有:search-service.js 的 backupSearchIndex 用的就是 new Promise((resolve, reject) => ...),還帶一個 rejectOnce 的小輔助函式確保只拒絕一次。兩個函式隔幾十行,一個記得帶 reject,一個沒有。
冷快取那條路上,有什麼東西會失敗
bulkPut 在整份原始碼裡只有一個呼叫點:search-service.js 第 175 行,await this.contentDb.bulkPut(searchableDocuments, 100),走的是「文件是現拉的」那個分支,也就是快取是冷的那條路。接著 index.js 第 162 行用 while (!searchServiceIsLoaded) 空轉並顯示解析圖示,而這條路的 .catch 就寫在同一個檔案第 139 行。.catch 存在,只是永遠等不到東西送進去。
冷快取那條路具體在做的事:從 /search/ 抓 14 個 JSON 檔(campaigns、assets、datacomponents、groups、matrices、misc、mitigations、resources、software、sub-techniques、tactics、techniques、detectionstrategies、analytics),串起來,對 title 與 content 建一份記憶體裡的 FlexSearch Document 索引,把索引匯出到 IndexedDB,再把文件本身以每塊 100 筆寫進去。
2026 年 9 月 16 日對線上站實測:這 14 個檔未壓縮合計 20,587,978 bytes(約 20.6 MB),共 3,413 份文件,只有冷快取那條路會走到。傳輸時是壓縮過的,例如 techniques.json 原始 4,234,161 bytes、gzip 後 1,403,357 bytes。文件數是拿 Python 的 json parser 數的而不是正則,其中 analytics.json 是一份 663 KB 的單一文件,所以 3,413 是真的文件數。每塊 100 筆,就是 35 次分塊寫入,任何一次失敗都會落進上面那個洞。
那些寫入會怎麼失敗,就照 API 文件寫的:Dexie 的文件說 bulkPut 以拒絕回報失敗(「If some operations fail, bulkPut() will ignore those failures and return a rejected Promise with a Dexie.BulkError referencing the failures」),DatabaseClosedError 則對應連線已被關閉的情況;MDN 的儲存配額頁說超過來源配額會以 QuotaExceededError 失敗,並建議把寫入瀏覽器儲存空間的程式碼包在 try...catch 裡。
這裡要說清楚的是:我沒有觀測到任何人真的踩到。沒有 issue、沒有遙測、沒有使用者回報,我在 PR 描述裡對「相關 issue」欄位的回答就是找不到,因為這個失敗是無聲的。這是一個潛在的失效模式,不是一次事故。它跟我們自己基礎建設上那種服務還活著但什麼都沒做的病是同一種,只是搬到瀏覽器裡的一個 promise 上。
怎麼測「卡住」
這種 bug 最容易寫出一個看起來合理、其實什麼都沒證明的測試。如果只寫 await expect(contentDb.bulkPut(data)).rejects.toThrow(),未修正的程式碼不會讓它變綠,但它也不會告訴你原因:它只會逾時,在報告裡長得跟一個比較慢的測試一模一樣。「卡住」和「拒絕」必須是兩個能被印出來的不同字串。
所以新的測試跟一個哨兵賽跑:
const settle = (promise) => Promise.race([
promise.then(() => 'resolved', (error) => `rejected:${error.message}`),
new Promise((resolve) => setTimeout(() => resolve('HUNG'), 1000)),
]);
斷言的對象是這個字串。我先把未修正的 indexed-db-wrapper.js 還原回去跑一次,兩個測試的輸出分別是 Expected "rejected:QuotaExceededError" / Received "HUNG"、Expected "rejected:DatabaseClosedError" / Received "HUNG"。那兩個錯誤名稱是拿來當測試替身的,取自上面那些文件記載的失效模式,不是我在線上觀測到的東西。
第二個測試刻意把失敗推到後面的分塊:chunkSize 傳 1,mock 只在第二次呼叫時拒絕。理由是分塊不是同一個 tick 跑完的,scheduleWork 在有 requestIdleCallback 的瀏覽器用它,沒有的(檔案裡一則 2023 年 4 月的註解寫的是 Safari)退回 setTimeout(callback, 10)。第一塊的失敗還在原本的呼叫堆疊上,後面的分塊已經在另一個排程回合裡,兩者要分別測。
index.js 那半邊的三個測試也一樣,我把未修正的 index.js 還原回去跑,三個具名測試全紅。有一個細節很說明問題:那次跑要加 --forceExit,因為舊的 search 迴圈根本不會結束。測試程序自己被同一個 bug 卡住了。
測試數字要帶基準線才有意義:分岔點 55660a5 是 42 個通過;加上 bulkPut 修正與兩個 wrapper 測試的 509e547 是 44;我的分支頭 0d58ced 再加三個測試是 47;而合併 commit 8fc68d0 是 68,因為 develop 自己在這 22 天裡也長了測試。今天去跑會看到 68,不是 47。
維護者說:它會 reject 了,使用者還是在看轉圈圈
PR 開在 2026 年 8 月 23 日 11:39:40Z。第一則維護者的回應在 12 天 6 小時 41 分之後:
Thanks! The underlying
bulkPutdiagnosis is definitely an issue, and I was able to confirm that the promise now rejects instead of hanging. But a cold cache catch leavessearchServiceIsLoadedfalse, whilesearch()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!
翻成中文:診斷他認可,也自己確認了 promise 現在會拒絕而不是掛住;但冷快取那個 catch 只會讓 searchServiceIsLoaded 維持 false,而 search() 就是在等它變 true,所以受影響的使用者照樣看到無盡的轉圈。
這句話的份量在於:我修好的是「失敗可以被回報」,他指出的是「失敗仍然看不見」。使用者眼裡的症狀一點都沒變。
第二輪的兩個 commit 在他留言後 2 小時 13 分內推上去,回覆在 2 小時 26 分。做法是加一個 searchServiceUnavailable 旗標、一個共用的 markSearchUnavailable()(連原本處理瀏覽器不支援 IndexedDB 的那條分支也改成走它),以及把迴圈條件改成「一旦確定索引不會來了就不要再等」。
寫這一段的時候順手撞到暖快取那條路上的反向缺陷:它的 finally 無條件把 searchServiceIsLoaded 設成 true,蓋掉自己的 catch 剛剛設成的 false。所以還原快取失敗時,系統回報的是載入成功。一邊是失敗被當成永遠沒完成,另一邊是失敗被當成完成了。
第三層是他自己補的
接著是 9 天 23 小時 27 分的安靜。2026 年 9 月 14 日 20:14:00Z,他推了自己的 commit ffd0522,20:17:11Z 留言,20:17:19Z 合併,距離他自己那個 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!
那個 commit 叫「fix(search): implement cache invalidation for failed search index builds」,3 個檔案、+30 / -2。它加了一個 invalidateSearchCache():移掉 localStorage 的快取標記、呼叫 searchService.db.indexeddb.delete(),兩件事各自包在自己的 try/catch 裡,附一則註解說明為什麼:
Cache cleanup is best-effort and must not mask the original initialization failure or prevent the unavailable UI state from being shown.
清快取是盡力而為,不可以蓋掉原本那個初始化失敗,也不可以害那個「無法使用」的畫面狀態顯示不出來。他把它接在暖快取的 catch 區塊裡,順手把我寫的暖快取測試擴充成也斷言 localStorage.removeItem(cacheKey) 有被呼叫、資料庫的 delete 被呼叫一次,並把 CHANGELOG 那一條改寫成「A failed restore from the cached index is discarded instead of being reported as a successful load or retried after every reload」。
那個快取標記是 localStorage 上一把形如 saved_uuid_search_schema_4-flexsearch-0.8.212 的鍵,由 searchCacheSchemaVersion = 4 和 package 裡的 flexsearch 版本組出來,線上部署的 bundle 用的是同一套規則。
把整件事攤平來看,是三層:我的修正讓失敗變得可回報,他的審查指出它仍然不可見,我的第二輪讓它可見,他自己的 commit 讓它可復原。而前兩層我是被告知第一層不夠之後才補上的,這句話得誠實寫進來。
流程的實話:22 天、零正式 review、零 CI,而且還沒上線
- 開到合併:22 天 8 小時 37 分 39 秒(536.63 小時)。
- 正式的 GitHub review:零。reviews 陣列是空的,review-comments 端點回的是
[]。所有回饋都是普通的 issue 留言,整串總共三則,兩則是維護者的、一則是我的。 - 2026 年 8 月 26 日 19:43:01Z,也就是開 PR 後 3 天 8 小時,jondricek 向 adpare 請求 review 並把 PR 指派給他。adpare 沒有留言也沒有 review。
- 合併者是 jondricek(Jared Ondricek)。可查證的是:他按下合併、他推了一個 commit 到這筆 PR、他的 GitHub 個人頁公司欄寫 The MITRE Corporation、那個 commit 的作者信箱是 jondricek@mitre.org、GitHub 標記他的留言 authorAssociation 是 CONTRIBUTOR。我不給他任何頭銜,因為這些來源證明不了頭銜。
- 合併方式是真的 merge commit(8fc68d0,兩個 parent),不是 squash 也不是 rebase。
- CI:
gh pr checks 637的輸出是這個分支上沒有任何 check。整個 repo 的 workflow 沒有一支跑 Jest 或 ESLint,唯一會被 PR 觸發的是 SonarCloud 掃描,另一支是 push 到 master 時建置並部署 GitHub Pages。這不是我的推測,repo 自己的AGENTS.md就寫著:「CI clearly builds the site and search bundle, but does not currently enforce Jest, ESLint, Stylelint, Ruff, or type checks.」
還有一件事我覺得有趣但沒有證據把它連起來。我開 PR 時 GitHub 送出來的模板來自 2019 年,第二條寫的是「Assign and/or mention a reviewer (typically @isaisabel)」,我照做了,開 PR 6 小時 50 分後編輯內文補上模板的標題、CHANGELOG 段落,以及一句「@isaisabel for review, per the template.」isaisabel 從頭到尾沒有出現。三天後,2026 年 8 月 26 日,jondricek 在 develop 上把 PR 模板換成一份四個標題的新版本。兩件事都是事實,任何地方都沒有證據顯示它們有因果關係。
最後是部署狀態。合併進的是 develop,而這個 repo 的 GitHub Pages workflow 只在 push 到 master 時部署,master 最新的 commit 停在 2026 年 8 月 7 日。我對線上產物直接驗過:抓下 attack.mitre.org 的 search_bundle.js(2026 年 9 月 16 日,447,975 bytes),用固定字串搜尋(不是正則),修正新增的兩句使用者可見字串都是 0 次;對照組 saved_uuid_search_schema_、requestIdleCallback、error-icon 都找得到,證明這個檢查有能力找到存在的字串。所以:已合併,但在寫這篇的時候還不在線上的 bundle 裡。AGENTS.md 對這個陷阱也有一句警語:「Do not assume master is the integration branch just because GitHub Pages deploys from it.」
AI 使用揭露:這次沒有政策,我也沒寫
前一篇 chainladder 的拆解裡,casact 有 AI 使用政策,我在 PR 裡寫了一段正式的揭露。這次是相反的情況,值得照實說。
這個 repo 當時沒有、現在也沒有任何 AI 或 AI 揭露政策。docs/CONTRIBUTING.md 只有兩件事:PR 要送 develop,以及同意 Developer's Certificate of Origin v1.1。沒有 CODE_OF_CONDUCT、沒有 SECURITY 政策,組織層級的 .github repo 不存在(API 回 404)。repo 裡確實有一份 200 行的 AGENTS.md,是維護者寫給在這個 repo 裡工作的 coding agent 看的(建置指令、風格、驗證、工作流程期待),裡面沒有任何揭露要求。
我的 PR 內文,原版和編輯後的版本,都沒有提到 AI,三則留言也沒有。唯一存在的痕跡在 git 紀錄裡:第二輪那兩個 commit(062f477、0d58ced)的訊息結尾帶著 Co-Authored-By: Claude Opus 5 (1M context) 的 trailer,第一輪那兩個沒有,結尾帶的是 Signed-off-by: ppcvote。
所以誠實的說法是:沒有政策要遵守,PR 裡沒有寫任何揭露,四個 commit 裡有兩個帶著機器可讀的共同作者標記,就這樣。順便把自己的疏漏也寫上:帶 trailer 的那兩個 commit 沒有 Signed-off-by 那一行,而那正是 CONTRIBUTING.md 要求的 DCO 機制。維護者沒有提這件事,不代表我做對了。
帶走的東西
- 「不 settle」和「reject」是兩種不同的失敗,只有第二種會走進你的錯誤處理。 設計失敗路徑時要問的不是「有沒有寫 catch」,是「有沒有東西保證這個 promise 一定會 settle」。這個 repo 的
.catch早就寫好了,寫在第 139 行,它只是永遠等不到東西。 - lint 指對了位置,不代表它的理由就是你的 bug 的理由。
no-async-promise-executor的紅字剛好落在第 26 行,但把async拿掉並不會讓那個 promise settle,我在 Node 22 上驗過。用它當線索,不要用它當結論。 - 要測「卡住」,就得讓「卡住」變成一個能被印出來的值。 哨兵賽跑把逾時變成字串
HUNG,於是報告上寫的是 Expected rejected / Received HUNG,而不是一個逾時的紅字。 - 可回報、可見、可復原是三件事。 我一開始只做到第一件,被指出後做到第二件,第三件是維護者自己補的。修一個失敗路徑之前,先把這三層分開問一次。
PR 原頁:mitre-attack/attack-website#637,+182 / -27,5 個檔案,其中我的四個 commit 是 +154 / -27、維護者那個是 +30 / -2。同樣是「語言規則跟直覺不一致」這一類的 bug,Python 的具名參數接不到 **kwargs 是另一個例子;更早的七筆 PR 總整理裡有這類 bug 的其他形狀。
常見問題
為什麼我的 JavaScript promise 既不 resolve 也不 reject?
因為 executor 裡面沒有任何人呼叫 resolve 或 reject。`new Promise(executor)` 建出來的 promise 只會被這兩個函式 settle,executor 自己回傳什麼都會被忽略。如果失敗發生在一個 async executor 裡,或發生在 executor 呼叫了卻沒有 await 的函式裡,那個拒絕會落在另一個沒有人持有的 promise 上。呼叫端拿到的不是錯誤,是永遠的沉默。
ESLint 的 no-async-promise-executor 在防什麼?把它清乾淨就等於修好 bug 嗎?
這條規則屬於 eslint:recommended,ESLint 官方文件給的理由是:若 async executor 函式拋出錯誤,該錯誤會遺失,不會讓新建出來的 Promise 被拒絕。但只是把 async 拿掉並不會讓 promise settle。我在 Node 22 上實測過:executor 不是 async、但裡面那個非同步呼叫一樣沒有 await 也沒有 try/catch 時,promise 照樣永遠 pending。真正的修法是 try/catch 裡呼叫 reject,lint 變乾淨只是副作用。
怎麼寫一個能證明 promise 卡住而不是被拒絕的測試?
跟一個哨兵賽跑。用 Promise.race 把受測的呼叫(成功映射成 resolved、失敗映射成 rejected 加訊息)和一個一秒後回傳字串 HUNG 的 setTimeout 放在一起,然後對結果字串做斷言。單純寫 expect(...).rejects 分不出這兩種情況,它只會逾時,讀起來像一個跑比較慢的測試。
IndexedDB 寫入超過瀏覽器配額會發生什麼事?
MDN 寫明:使用 IndexedDB、Cache 或 OPFS 等方式儲存超過來源配額的資料,會以 QuotaExceededError 例外失敗,並建議把寫入瀏覽器儲存空間的 JavaScript 包在 try...catch 裡。MDN 把 QuotaExceededError 定義為「當請求的操作會超出系統施加的儲存配額時」拋出的錯誤,它是 DOMException 的子類別。用 Dexie 的話,bulkPut 會回傳一個帶著 Dexie.BulkError 的被拒絕 promise。至於呼叫端看不看得到這個拒絕,要看有沒有人持有那個 promise。
這個修正已經上 attack.mitre.org 了嗎?
截至 2026 年 9 月 16 日還沒有。PR #637 在 2026 年 9 月 14 日合併進 develop 分支,而該 repo 的 GitHub Pages workflow 只在 push 到 master 時部署,master 最新的 commit 停在 2026 年 8 月 7 日。我抓下線上的 search_bundle.js 逐字搜尋,修正新增的字串一個都不在裡面。