Skip to content

Fix reporting handled promises as unhandled in tracker #1038

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 1, 2025

Conversation

saghul
Copy link
Contributor

@saghul saghul commented Apr 28, 2025

Enqueue a job which will perform the check after all reactions. Also drop the case in which the tracker gets called with true, the handler now only gets called when the promise rejection is unhandled.

Fixes: #39

quickjs.h Outdated
@@ -1020,10 +1020,9 @@ typedef void JSPromiseHook(JSContext *ctx, JSPromiseHookType type,
JS_EXTERN void JS_SetPromiseHook(JSRuntime *rt, JSPromiseHook promise_hook,
void *opaque);

/* is_handled = true means that the rejection is handled */
/* only gets called on unhandled rejections. */
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bnoordhuis LMK what you think about this change. I can drop the 2nd commit and leave it more backwards compatible.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without is_handled, you can't implement async_hooks, so either it needs to be restored or there should be a promise hook for rejected promises.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, I'll rework it.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without is_handled, you can't implement async_hooks, so either it needs to be restored or there should be a promise hook for rejected promises.

It would be great if the new Promise Hook API could also pick up rejections. :)
It is a bit complicated that the methods for detecting asynchronous events are divided into the PromiseHook API, Promise Rejection Tracker, and FinalizationRegistry, so I think it would be a good idea to integrate the Promise Rejection Tracker into the PromiseHook API. However, I don't know the history of the Promise Rejection Tracker, so if you can't integrate it, that's fine.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose I could add a new hook JS_PROMISE_HOOK_REJECT and then an extra API JS_PromiseIsHandled and we could drop this altogether.

Thoughts @bnoordhuis ?

quickjs.h Outdated
@@ -1020,10 +1020,9 @@ typedef void JSPromiseHook(JSContext *ctx, JSPromiseHookType type,
JS_EXTERN void JS_SetPromiseHook(JSRuntime *rt, JSPromiseHook promise_hook,
void *opaque);

/* is_handled = true means that the rejection is handled */
/* only gets called on unhandled rejections. */
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without is_handled, you can't implement async_hooks, so either it needs to be restored or there should be a promise hook for rejected promises.

@saghul saghul force-pushed the fix-unhandled-rejection-tracker branch 2 times, most recently from 77bb476 to 0ee636b Compare April 30, 2025 10:56
Enqueue a job which will perform the check after all reactions.

Fixes: #39
@saghul saghul force-pushed the fix-unhandled-rejection-tracker branch from 0ee636b to 2358d21 Compare April 30, 2025 11:05
@saghul
Copy link
Contributor Author

saghul commented Apr 30, 2025

@bnoordhuis Updated, PTAL!

@saghul saghul merged commit a75498b into master May 1, 2025
127 checks passed
@saghul saghul deleted the fix-unhandled-rejection-tracker branch May 1, 2025 09:18
@ChALkeR
Copy link

ChALkeR commented May 2, 2025

@saghul Still reproducible on this:

const asyncFunction = async () => {
  throw new Error('this function rejects')
}

async function run() {
  const error = await Promise.resolve().then(function () {
    var resultPromise = asyncFunction();
    return Promise.resolve().then(function () {
      return resultPromise;
    }).then(function () {
      return null;
    }).catch(function (e) {
      return e;
    });
  });
  console.log('Got this error:', error)
}

run().then(() => console.log('done'))

Code mostly extracted from http://npmjs.com/package/assert assert.rejects() (which also crashes on QuickJS)

@saghul
Copy link
Contributor Author

saghul commented May 2, 2025

Dammit, I'll take another look! Thanks for the test case!

@ChALkeR
Copy link

ChALkeR commented May 2, 2025

Simplified test case:

const error = await Promise.resolve().then(() => Promise.reject('reject')).catch(e => e)
console.log('Got this error:', error)

ChALkeR added a commit to ExodusMovement/test that referenced this pull request May 2, 2025
@saghul
Copy link
Contributor Author

saghul commented May 6, 2025

@ChALkeR My attempt at covering that: #1049

@andycall
Copy link

I don't believe this patch is correct—it introduces behavior that's inconsistent with Web browser implementations.

Take the following code example in a web browser. Only 'rejected' is printed to the console:

window.addEventListener('unhandledrejection', event => {
  console.log('unhandledrejection fired: ' + event.reason);
});

window.addEventListener('rejectionhandled', event => {
  console.log('rejectionhandled fired: ' + event.reason);
});

function generateRejectedPromise(isEventuallyHandled) {
  // Create a promise which immediately rejects with a given reason.
  var rejectedPromise = Promise.reject('Error at ' + new Date().toLocaleTimeString());
  rejectedPromise.catch(() => {
    console.log('rejected');
  });
}

generateRejectedPromise(true);

This demonstrates that the caller (the Window object) tracks unhandled promise rejections. The initial rejection is considered unhandled and would trigger the unhandledrejection event. Later, when .catch() is added, the promise becomes handled.

For users who register a callback with JS_SetHostPromiseRejectionTracker, they should simply record the reported promise pointer in a map, and remove it once the promise is handled by user code.

Reference:
https://github.com/openwebf/webf/blob/main/bridge/core/executing_context.cc#L630

However, the current patch sets is_handled to true for both the unhandled and handled states, making it impossible to accurately detect whether a promise was truly handled or not. This undermines the ability to track unhandled rejections as the spec and browser behavior intend.

@andycall
Copy link

I don't believe the Promise feature in Bellard's 2021-03-27 version had any issues.

The problems stemmed from a misunderstanding of how Promise rejections were handled and reported. I've used this implementation in WebF for over four years, powering frameworks like React, Vue, and Jasmine, as well as open-source tools such as Ant Design—from its initial open-source phase to its application in enterprise crypto projects.

Rolling back to Bellard’s original implementation and referring to the WebF usage should resolve the issues.

@saghul
Copy link
Contributor Author

saghul commented May 16, 2025

I don't believe this patch is correct—it introduces behavior that's inconsistent with Web browser implementations.

I don't think the intention was that from the start though. Rather, how to terminate the process in unhandled rejection cases which server runtimes implement.

This demonstrates that the caller (the Window object) tracks unhandled promise rejections. The initial rejection is considered unhandled and would trigger the unhandledrejection event. Later, when .catch() is added, the promise becomes handled.

How would a server runtime know when to terminate the process in case of an unhandled rejection then?

However, the current patch sets is_handled to true for both the unhandled and handled states, making it impossible to accurately detect whether a promise was truly handled or not. This undermines the ability to track unhandled rejections as the spec and browser behavior intend.

Note I made a follow-up PR handling more corner cases.

@andycall
Copy link

How would a server runtime know when to terminate the process in case of an unhandled rejection then?

I think the termination of the process should depend on the currently enqueued microtasks or any active event listeners. Regardless of whether the callback executes successfully or throws an exception, the process should not terminate immediately. If an exception is thrown, it should simply emit an event on globalThis.

@saghul
Copy link
Contributor Author

saghul commented May 16, 2025

Node, Deno and Bun disagree so there is ample precedent here.

@bnoordhuis
Copy link
Contributor

If an exception is thrown, it should simply emit an event on globalThis.

It sounds like you're thinking of what Node.js does but that's in no way standard. There is no standard, it's up to the embedder - not the JS engine - to decide what's best.

@andycall
Copy link

andycall commented May 16, 2025

Emm, if you wish to terminate the process when an unhandled async exception is thrown, it might be better to perform the check at the end of this enqueue job instead of immediately. This is because the user might catch and handle the error after the promise is created.

// Finish all the pending jobs
int finished = JS_ExecutePendingJob(script_state_.runtime(), &pctx);

while (finished != 0) {
  finished = JS_ExecutePendingJob(script_state_.runtime(), &pctx);
  if (finished == -1) {
    break;
  }
}

// Terminate the process if there are unhandled exceptions.
if (haveUnhandledExceptions) {
  exit(1);
}

@saghul
Copy link
Contributor Author

saghul commented May 17, 2025

Let me give that a try.

saghul added a commit that referenced this pull request May 19, 2025
This reverts a75498b and
a4a5a17 (except the tests) and adapts
bellard/quickjs@3c39307
which also passes the tests and feels like a better solution overall.

Ref: #1038
@saghul
Copy link
Contributor Author

saghul commented May 19, 2025

Fix: #1058

saghul added a commit that referenced this pull request May 19, 2025
This reverts a75498b and
a4a5a17 (except the tests) and adapts
bellard/quickjs@3c39307
which also passes the tests and feels like a better solution overall.

Ref: #1038
saghul added a commit that referenced this pull request May 20, 2025
This reverts a75498b and
a4a5a17 (except the tests) and adapts
bellard/quickjs@3c39307
which also passes the tests and feels like a better solution overall.

Ref: #1038
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Unhandled promise tracker called incorrectly
5 participants