Skip to content

cljs: handle returning a Throwable rejects with the wrapper promise, not the exception (regression in 12.0.0) #171

Description

@burinc

On ClojureScript, a handle function that returns a Throwable rejects the resulting promise with the internal wrapper promise rather than with the exception. The downstream catch receives a PromiseImpl, so ex-message is nil and the reason is lost. The JVM does the right thing on the same code, and so does ClojureScript on 11.0.678.

Repro

deps.edn:

{:deps {org.clojure/clojure       {:mvn/version "1.12.3"}
        org.clojure/clojurescript {:mvn/version "1.11.132"}
        funcool/promesa           {:mvn/version "12.0.1"}}
 :paths ["src"]}

src/probe/core.cljs:

(ns probe.core
  (:require [promesa.core :as p]))

(defn show [label e]
  (println label "| type:" (pr-str (type e))
           "| ex-message:" (pr-str (ex-message e))
           "| js/Error?:" (instance? js/Error e)))

(defn -main []
  (-> (p/rejected (ex-info "plain" {}))
      (p/catch (fn [e] (show "plain catch  " e))))

  (-> (p/rejected (ex-info "via-handle" {}))
      (p/then   (fn [r] {:ok r}))
      (p/handle (fn [result error] (or error result)))
      (p/catch  (fn [e] (show "handle->catch" e)))))

(-main)
clojure -M -m cljs.main --target node --output-to out.js --compile probe.core
node out.js

Result

promesa 11.0.678:

plain catch   | type: #object[cljs$core$ExceptionInfo] | ex-message: "plain" | js/Error?: true
handle->catch | type: #object[cljs$core$ExceptionInfo] | ex-message: "via-handle" | js/Error?: true

promesa 12.0.1:

plain catch   | type: #object[cljs$core$ExceptionInfo] | ex-message: "plain" | js/Error?: true
handle->catch | type: #object[PromiseImpl] | ex-message: nil | js/Error?: false

A plain catch is unaffected. Only the value that has passed through handle is wrapped.

Where it happens

JVM ClojureScript
11.0.678 ok ok
12.0.0 ok PromiseImpl
12.0.1 ok PromiseImpl
master 9b38ac3 (2026-07-31) ok PromiseImpl

So it arrived in 12.0.0 and is still on master.

Why this looks like a bug rather than misuse

handle's own docstring says the returned promise is "completed (resolving or rejecting) with the return value of calling f", and that "f can return a new plain value or promise instance, and automatic unwrapping will be performed". Returning a Throwable to reject is what that describes, and the JVM branch honours it.

Whatever the intended reading, a PromiseImpl in the error position is neither of the two sensible outcomes. It is not the exception, and it is not the exception resolved as a value.

Mechanism

handle is implemented as:

#?(:cljs (c/-> (pt/-promise p)
               (pt/-hmap (comp pt/-promise f))
               (pt/-mcat identity))
   :clj  (c/-> (pt/-promise p)
               (pt/-hmap (comp pt/-promise f))
               (util/unwrap-completion-stage)))

pt/-promise on a Throwable correctly gives a rejected promise. Verified on cljs with 12.0.1:

-promise on a Throwable REJECTED with: #object[cljs$core$ExceptionInfo] msg "coerced"

So (comp pt/-promise f) produces a rejected inner promise, and the cljs (pt/-mcat identity) does not flatten it into the outer rejection the way the JVM's unwrap-completion-stage does. The inner promise ends up as the rejection reason. That matches a plain Promise.reject(aPromise), which does not unwrap its argument the way resolve does.

Impact

Any cljs code using handle for cleanup and passing the error through, then relying on a later catch, silently loses the exception. In our case a JSON-RPC error response fell back from carrying the handler's reason to a bare "Internal error", with nothing thrown and no warning. It was caught only because two tests asserted on the message.

Environment

  • macOS arm64, node, ClojureScript 1.11.132, cljs.main node target
  • Verified 2026-09-01 against 11.0.678, 12.0.0, 12.0.1, and master 9b38ac3

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions