1295 字
6 分鐘

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。

如果一項 Promise 永遠 pending,allSettled 也會永遠等。外層 Promise.race 的 timeout 只會停止等待,沒有證明底層寫入已停下;在這種情況下直接刪除共享目錄會重現同一個問題。應在任務層設定可生效的取消與逾時,或把未能停止的工作隔離到不同資源,依底層 API 的終止契約清理。

需要保留多個 API 的部分成功畫面,可讀 Promise.allSettled 的命名結果處理;需要 fetch 逾時與取消原因,則看 AbortSignal.timeout 的生命週期。這篇處理的是「最後一個資源使用者何時離開」,即使產品最後決定整批失敗,也要把這個時間點確認清楚。

參考資料:

MDN:Promise.all()

MDN:Promise.allSettled()

ECMAScript:Promise.all

Promise.all 失敗後還在執行?等所有工作結束再清理資源
https://laplusda.com/posts/javascript-promise-all-cleanup-after-rejection/
作者
Zero
發佈於
2026-10-07
許可協議
CC BY-NC-SA 4.0