Promise.all 失敗後還在執行?等所有工作結束再清理資源
兩個匯出工作共用一個暫存目錄,其中一個失敗,await Promise.all() 立刻跳進 finally 刪除目錄;另一個工作卻還在寫檔。這時後續的「找不到檔案」可能是清理流程造成的第二個錯誤,原本的失敗反而被埋掉。
Promise.all 提早回報失敗,不會取消其他工作,也不會等它們結束才執行你的 finally。 共用資源的生命週期必須另外管理:保留每個已啟動工作的 Promise,等它們全數 settled,再關閉連線或移除暫存資源。
用完成順序重現,不靠計時器碰運氣
以下存成 early-cleanup.mjs,執行 node early-cleanup.mjs。它只模擬共享資源是否仍存在,不會真的刪除檔案:
import assert from 'node:assert/strict';
function deferred() { let resolve; let reject; const promise = new Promise((ok, fail) => { resolve = ok; reject = fail; }); return { promise, resolve, reject };}
const gate = deferred();let resourceOpen = true;const writes = [];const slow = gate.promise.then(() => writes.push(resourceOpen));const failed = Promise.reject(new Error('匯出失敗'));
try { await Promise.all([failed, slow]);} catch (error) { assert.equal(error.message, '匯出失敗');} finally { resourceOpen = false;}
gate.resolve();await slow;assert.deepEqual(writes, [false]);console.log('提早清理時,另一個工作仍未結束');測試故意等清理之後才讓第二項繼續,因此沒有「今天網路剛好快一點」的變因。MDN 的 Promise.all 說明與 ECMAScript 演算法定義聚合結果如何成功或 reject,並沒有替任務加入取消操作。
把等待全部結束放進共用資源邊界
下面的 wrapper 採用「必須全部成功」的結果規則:成功時回傳值陣列;失敗時等所有已建立工作結束,再拋回 Promise.all 觀察到的原始失敗原因。
async function allOrWait(tasks) { const promises = tasks.map(task => Promise.resolve().then(task)); try { return await Promise.all(promises); } catch (error) { await Promise.allSettled(promises); throw error; }}輸入是工作函式陣列,例如 () => exportPart()。用 Promise.resolve().then(task) 啟動,讓同步 throw 也變成該項 rejected Promise;若直接 tasks.map(task => task()),第二項同步拋錯就可能中斷 map,使你拿不到第一項已啟動工作的完整清單。
tasks 應是預先準備好的函式陣列;這個 wrapper 沒有替任意 iterator、任務依賴或並行數做管理。工作本身也必須回傳涵蓋完整操作的 Promise,不能啟動背景寫入後立即回傳成功。
接到共享資源時,使用方式如下。openWorkspace、exportPart 與 closeWorkspace 是你的產品介面,這一段是整合示意:
const workspace = await openWorkspace();let workFailed = false;
try { await allOrWait([ () => exportPart(workspace, 'a'), () => exportPart(workspace, 'b'), ]);} catch (error) { workFailed = true; throw error;} finally { try { await closeWorkspace(workspace); } catch (cleanupError) { if (!workFailed) throw cleanupError; console.error('工作已失敗,另外記錄清理錯誤', cleanupError); }}這裡讓工作失敗優先保留,清理失敗另外記錄。若直接在 finally 裡 await closeWorkspace(),清理拋出的錯誤可能蓋過原始錯誤;若兩者都要交給上層,可改用專案定義的複合錯誤,並在 logging 保留各自來源。
驗證 wrapper 真的沒有提早結束
把 deferred、allOrWait 和下面測試放進同一份 wait-check.mjs。這次不是只檢查最終回傳值,也會確認拒絕已發生、第二項仍 pending 時,聚合尚未完成:
import assert from 'node:assert/strict';
const entered = deferred();const gate = deferred();const originalError = new Error('第一項失敗');let completed = false;let resourceOpen = true;const seen = [];
const batch = allOrWait([ () => { throw originalError; }, async () => { entered.resolve(); await gate.promise; seen.push(resourceOpen); },]);const observed = batch.then( () => { completed = true; }, error => { completed = true; assert.equal(error, originalError); },);
await entered.promise;await Promise.resolve();assert.equal(completed, false);gate.resolve();await observed;resourceOpen = false;assert.deepEqual(seen, [true]);assert.deepEqual(await allOrWait([]), []);assert.deepEqual(await allOrWait([() => 1, () => 2]), [1, 2]);console.log('等待與原始錯誤保留檢查通過');本文的兩份狀態測試由本次內容維護在 Node.js v24.13.0 執行。它驗證 Promise 與清理時點,沒有連接真實資料庫、檔案匯出或瀏覽器;接入產品後仍須確認每個回傳的 Promise 確實包含完整工作。
等待完成與取消工作是兩個決定
allOrWait 刻意延後失敗回報,因此適合必須收束所有工作才能釋放資源的地方。若希望一失敗就要求其他工作停止,要讓任務接受 AbortSignal,在 catch 裡取消,再等待那些工作結束。不能只呼叫 abort() 就假設資源已停止使用;某項操作可能不支援 signal,或需要完成自己的 cleanup。
失敗後先要求取消,再等每項工作收尾
如果所有工作都接受同一個 AbortSignal,可把前面的 wrapper 改成以下版本。這是本文定義的 helper,並非 JavaScript 內建的取消聚合器;輸入限定為預先準備好的普通函式陣列,每個函式接收 signal,回傳包含自身 cleanup 的完整 Promise。
async function allOrAbortAndWait(tasks) { const controller = new AbortController(); const promises = tasks.map(task => Promise.resolve().then(() => { controller.signal.throwIfAborted(); return task(controller.signal); }));
try { return await Promise.all(promises); } catch (error) { controller.abort(error); await Promise.allSettled(promises); throw error; }}abort(error) 通知其他工作停止,allSettled 則等它們真正結束。最後仍拋回 Promise.all 觀察到的失敗原因,避免取消時其他工作產生的例外蓋掉最初錯誤。同步 throw 也透過 .then() 轉成 rejection;這裡沒有取消呼叫端的 controller,也沒有提供外部取消介面或並行數限制。
MDN 的可取消 API 範例要求處理已中止狀態及後續 abort,並在正常結束時移除 listener。{ once: true } 只在 abort 發生時移除監聽器;如果工作正常完成,也要自行移除。工作內不能先回傳成功,再讓背景寫入或 cleanup 繼續跑。
驗證 abort 已發生,wrapper 卻仍在等 cleanup
將前面的 deferred、allOrAbortAndWait 與下面程式存成同一份 .mjs,用 Node.js 執行。第二項收到取消後,故意等另一個 gate 才完成 cleanup;測試必須證明這段期間 wrapper 尚未 reject。
import assert from 'node:assert/strict';
const started = deferred();const aborted = deferred();const finishCleanup = deferred();const original = new Error('匯出失敗');const cleanupError = new Error('收尾另外失敗');const events = [];let completed = false;
const batch = allOrAbortAndWait([ async () => { await started.promise; throw original; }, async signal => { signal.throwIfAborted(); const onAbort = () => aborted.resolve(signal.reason); signal.addEventListener('abort', onAbort, { once: true }); started.resolve(); try { const reason = await aborted.promise; assert.equal(reason, original); throw reason; } finally { signal.removeEventListener('abort', onAbort); events.push('開始收尾'); await finishCleanup.promise; events.push('收尾完成'); throw cleanupError; } },]);const observed = batch.then( () => assert.fail('整批不應成功'), error => { completed = true; assert.equal(error, original); },);
await aborted.promise;await Promise.resolve();assert.equal(completed, false);assert.deepEqual(events, ['開始收尾']);finishCleanup.resolve();await observed;assert.equal(completed, true);assert.deepEqual(events, ['開始收尾', '收尾完成']);assert.deepEqual(await allOrAbortAndWait([]), []);assert.deepEqual(await allOrAbortAndWait([() => 1, () => 2]), [1, 2]);console.log('取消通知、等待收尾與原始錯誤保留檢查通過');這份新增測試由內容維護流程於 2026-10-27 在 Node.js v24.13.0 執行;另檢查同步 throw、忽略 signal 的工作仍須等待及成功結果順序。它沒有操作真實暫存目錄、資料庫或網路,也沒有證明伺服器端工作已停止。fixture 的 cleanup 故意拋錯,以確認 wrapper 保留原始失敗;實際產品仍應在各工作內另記 cleanupError,避免遺失收尾失敗資訊。
有工作不接受 signal,就不能保證提早停止
如果一項 Promise 永遠 pending,allSettled 也會永遠等。外層 Promise.race 的 timeout 只會停止等待,沒有證明底層寫入已停下;在這種情況下直接刪除共享目錄會重現同一個問題。應在任務層設定可生效的取消與逾時,或把未能停止的工作隔離到不同資源,依底層 API 的終止契約清理。
需要保留多個 API 的部分成功畫面,可讀 Promise.allSettled 的命名結果處理;需要 fetch 逾時與取消原因,則看 AbortSignal.timeout 的生命週期。這篇處理的是「最後一個資源使用者何時離開」,即使產品最後決定整批失敗,也要把這個時間點確認清楚。
參考資料: