From c1a634aebddc2328b30ed5c63971ac08adb29259 Mon Sep 17 00:00:00 2001 From: Yuxi Liu <33951560+yuxi-liu-wired@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:36:09 +0000 Subject: [PATCH] :bug: Make promise task outcome task-local in processNextTick The drain loop kept `state` across tasks of the same queue, so a task that threw left `state` as REJECTED for every following task, and a reject handler that returned a value never switched it back to RESOLVED. Since (p/resolved nil) returns a shared NULL_PROMISE, independent chains started from nil share one queue, and an exception in one chain corrupted the result of the others. Each task's state/value is now computed only from the source promise and its own handler. A reject handler that returns normally recovers (RESOLVED); one that throws rejects. `handle` goes back to using defaultRejectHandler (as in 11.0.678) so rejections still reach its completion callback as the cause. To avoid paying for a throw on every propagated rejection, a task whose reject handler is defaultRejectHandler gets the same outcome the rethrow would produce, without calling it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019iotb3eKMwNTZTnsonJb3F --- src/promesa/impl/promise.js | 34 +++++++++++++++++++++++-------- test/promesa/tests/core_test.cljc | 33 ++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/src/promesa/impl/promise.js b/src/promesa/impl/promise.js index 999e45a..2b72c15 100644 --- a/src/promesa/impl/promise.js +++ b/src/promesa/impl/promise.js @@ -146,7 +146,7 @@ goog.scope(function() { this[QUEUE].push({ type: resolveType, resolve: defaultResolveMapHandler, - reject: defaultResolveMapHandler, + reject: defaultRejectHandler, complete: fn, // metadata: { // origin: goog.getUid(this), @@ -284,9 +284,8 @@ goog.scope(function() { function processNextTick(p) { if (p[QUEUE].length === 0) return; - const value = p[VALUE]; - let state = p[STATE]; - let task, rvalue; + const pstate = p[STATE]; + const pvalue = p[VALUE]; // console.log(":: process:", // "uid:", goog.getUid(p), @@ -295,12 +294,29 @@ goog.scope(function() { // "value:", fmtValue(p[VALUE])); while (p[QUEUE].length) { - task = p[QUEUE].shift(); + // The outcome (state/rvalue) is task-local: each task is + // computed only from the source promise state/value and its own + // handler, never from the outcome of a previous task in the + // same queue (several independent chains may share the same + // source promise, e.g. the shared NULL_PROMISE). + const task = p[QUEUE].shift(); + let state, rvalue; + try { - if (state === RESOLVED) { - rvalue = task.resolve(value) - } else if (state === REJECTED) { - rvalue = task.reject(value) + if (pstate === RESOLVED) { + rvalue = task.resolve(pvalue); + state = RESOLVED; + } else if (pstate === REJECTED) { + if (task.reject === defaultRejectHandler) { + // Same outcome as calling defaultRejectHandler (which + // rethrows the cause, see the catch block below), without + // the cost of throwing. + state = REJECTED; + rvalue = (task.type === RESOLVE_TYPE_BIND) ? self.rejected(pvalue) : pvalue; + } else { + rvalue = task.reject(pvalue); + state = RESOLVED; + } } else { rvalue = new TypeError("invalid state"); state = REJECTED; diff --git a/test/promesa/tests/core_test.cljc b/test/promesa/tests/core_test.cljc index dfde2f0..65a093c 100644 --- a/test/promesa/tests/core_test.cljc +++ b/test/promesa/tests/core_test.cljc @@ -96,6 +96,39 @@ (p/reject! p1 (ex-info "boom" {})))))) +#?(:cljs + (t/deftest independent-chains-from-nil-do-not-share-outcome + ;; (p/resolved nil) returns a shared instance, so both chains are + ;; queued on the same promise; the exception of the first chain + ;; should not leak into the second one. + (t/async done + (let [a (p/then (p/resolved nil) (fn [_] (throw (ex-info "A" {})))) + b (p/then (p/resolved nil) (fn [_] "b-ok")) + a' (.catch ^js a (fn [_] nil))] + (->> (p/wait-all a' b) + (p/fnly (fn [_ _] + (t/is (p/rejected? a)) + (t/is (= "A" (ex-message (p/extract a)))) + (t/is (p/resolved? b)) + (t/is (= "b-ok" (p/extract b))) + (done)))))))) + +#?(:cljs + (t/deftest throw-in-then-recover-in-catch-and-continue + (t/async done + (let [c (-> ^js (p/resolved nil) + (.then (fn [_] (throw (ex-info "C" {})))) + (.catch (fn [e] (str "recovered:" (ex-message e)))) + (.then (fn [v] (str v ":continued")))) + d (p/then (p/resolved nil) (fn [_] "d-ok"))] + (->> (p/wait-all c d) + (p/fnly (fn [_ _] + (t/is (p/resolved? c)) + (t/is (= "recovered:C:continued" (p/extract c))) + (t/is (p/resolved? d)) + (t/is (= "d-ok" (p/extract d))) + (done)))))))) + (t/deftest rejected-and-wrap-on-catch (let [p1 (p/rejected (ex-info "foobar" {})) p2 (p/catch p1 (fn [cause]