Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/workerd/api/global-scope.c++
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,8 @@ void ServiceWorkerGlobalScope::emitPromiseRejection(jsg::Lock& js,
};

if (hasHandlers() || hasInspector()) {
unhandledRejections.setUseMicrotasksCompletedCallback(
FeatureFlags::get(js).getUnhandledRejectionAfterMicrotaskCheckpoint());
unhandledRejections.report(js, event, kj::mv(promise), kj::mv(value));
}
}
Expand Down
7 changes: 7 additions & 0 deletions src/workerd/api/tests/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,13 @@ wd_test(
data = ["global-scope-test.js"],
)

wd_test(
size = "large",
src = "unhandled-rejection-test.wd-test",
args = ["--experimental"],
data = ["unhandled-rejection-test.js"],
)

wd_test(
size = "large",
src = "htmlrewriter-test.wd-test",
Expand Down
223 changes: 223 additions & 0 deletions src/workerd/api/tests/unhandled-rejection-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
// Regression tests for https://github.com/cloudflare/workerd/issues/6020
// Unhandled rejection should NOT fire for promises that are handled through
// multi-tick promise chains.

import { strictEqual, ok, rejects } from 'node:assert';
import { mock } from 'node:test';

const asyncFunction = async (name) => {
throw new Error(`this function rejects: ${name}`);
};

// Verifies assert.rejects handles rejections without unhandledrejection.
export const assertRejects = {
async test() {
const handler = mock.fn();
addEventListener('unhandledrejection', handler);
try {
await rejects(async () => asyncFunction('A'));
strictEqual(
handler.mock.callCount(),
0,
'unhandledrejection should not fire for assert.rejects'
);
} finally {
removeEventListener('unhandledrejection', handler);
}
},
};

// Verifies chained .then().catch() handling avoids unhandledrejection.
export const promiseChainCatch = {
async test() {
const handler = mock.fn();
addEventListener('unhandledrejection', handler);
try {
const error = await Promise.resolve()
.then(() => asyncFunction('B'))
.then(() => null)
.catch((e) => e);
ok(error instanceof Error);
strictEqual(error.message, 'this function rejects: B');
strictEqual(
handler.mock.callCount(),
0,
'unhandledrejection should not fire for .catch() chain'
);
} finally {
removeEventListener('unhandledrejection', handler);
}
},
};

// Verifies try/catch around awaited chain avoids unhandledrejection.
export const tryCatchAwait = {
async test() {
const handler = mock.fn();
addEventListener('unhandledrejection', handler);
try {
try {
await Promise.resolve('C').then(asyncFunction);
} catch (error) {
ok(error instanceof Error);
strictEqual(error.message, 'this function rejects: C');
}
strictEqual(
handler.mock.callCount(),
0,
'unhandledrejection should not fire for try/catch'
);
} finally {
removeEventListener('unhandledrejection', handler);
}
},
};

// Verifies a truly unhandled rejection still emits unhandledrejection.
export const genuineUnhandledRejectionStillFires = {
async test() {
const { promise, resolve } = Promise.withResolvers();
const handler = mock.fn(() => resolve());
addEventListener('unhandledrejection', handler, { once: true });
Promise.reject('boom');
await promise;
strictEqual(
handler.mock.callCount(),
1,
'unhandledrejection should fire for genuinely unhandled rejection'
);
},
};

// Verifies unhandledrejection fires after a Promise.resolve tick.
export const unhandledRejectionAfterPromiseResolve = {
async test() {
const { promise, resolve } = Promise.withResolvers();
const handler = mock.fn(() => resolve());
addEventListener('unhandledrejection', handler, { once: true });
Promise.reject('boom');
await Promise.resolve();
await promise;
strictEqual(
handler.mock.callCount(),
1,
'unhandledrejection should fire after Promise.resolve'
);
},
};

// Verifies unhandledrejection followed by rejectionhandled on late catch.
export const lateHandlerTriggersRejectionhandled = {
async test() {
const { promise: unhandledPromise, resolve: resolveUnhandled } =
Promise.withResolvers();
const { promise: handledPromise, resolve: resolveHandled } =
Promise.withResolvers();
let unhandledReason;
let handledReason;
const unhandledHandler = mock.fn((event) => {
unhandledReason = event.reason;
resolveUnhandled();
});
const handledHandler = mock.fn((event) => {
handledReason = event.reason;
resolveHandled();
});
addEventListener('unhandledrejection', unhandledHandler, { once: true });
addEventListener('rejectionhandled', handledHandler, { once: true });
try {
const error = new Error('late');
const promise = Promise.reject(error);
await unhandledPromise;
promise.catch(() => {});
await handledPromise;
strictEqual(
unhandledHandler.mock.callCount(),
1,
'unhandledrejection should fire once before late handler'
);
strictEqual(
handledHandler.mock.callCount(),
1,
'rejectionhandled should fire after late handler'
);
ok(unhandledReason instanceof Error);
strictEqual(unhandledReason.message, 'late');
strictEqual(
handledReason,
undefined,
'rejectionhandled reason should be undefined'
);
} finally {
removeEventListener('unhandledrejection', unhandledHandler);
removeEventListener('rejectionhandled', handledHandler);
}
},
};

// Verifies unhandledrejection handler can trigger another unhandled rejection.
export const handlerTriggeredUnhandledRejection = {
async test() {
const { promise, resolve } = Promise.withResolvers();
const timeout = new Promise((resolveTimeout) => {
setTimeout(resolveTimeout, 25);
});
const reasons = [];
let callCount = 0;
const handler = mock.fn((event) => {
reasons.push(event.reason);
callCount += 1;
if (callCount === 1) {
queueMicrotask(() => Promise.reject(new Error('second')));
}
if (callCount === 2) {
resolve();
}
});
addEventListener('unhandledrejection', handler);
try {
Promise.reject(new Error('first'));
await Promise.race([promise, timeout]);
strictEqual(
handler.mock.callCount(),
2,
'unhandledrejection should fire for rejection triggered by handler'
);
strictEqual(reasons.length, 2);
ok(reasons[0] instanceof Error);
strictEqual(reasons[0].message, 'first');
ok(reasons[1] instanceof Error);
strictEqual(reasons[1].message, 'second');
} finally {
removeEventListener('unhandledrejection', handler);
}
},
};

// Verifies each unhandled rejection emits its own event.
export const multipleUnhandledRejections = {
async test() {
const { promise, resolve } = Promise.withResolvers();
const timeout = new Promise((resolveTimeout) => {
setTimeout(resolveTimeout, 25);
});
const handler = mock.fn(() => {
if (handler.mock.callCount() === 2) {
resolve();
}
});
addEventListener('unhandledrejection', handler);
try {
Promise.reject(new Error('one'));
Promise.reject(new Error('two'));
await Promise.race([promise, timeout]);
strictEqual(
handler.mock.callCount(),
2,
'unhandledrejection should fire for each unhandled rejection'
);
} finally {
removeEventListener('unhandledrejection', handler);
}
},
};
17 changes: 17 additions & 0 deletions src/workerd/api/tests/unhandled-rejection-test.wd-test
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using Workerd = import "/workerd/workerd.capnp";

const unitTests :Workerd.Config = (
services = [
( name = "unhandled-rejection-test",
worker = (
modules = [
(name = "worker", esModule = embed "unhandled-rejection-test.js")
],
compatibilityFlags = [
"nodejs_compat",
"unhandled_rejection_after_microtask_checkpoint",
]
)
),
],
);
7 changes: 7 additions & 0 deletions src/workerd/io/compatibility-date.capnp
Original file line number Diff line number Diff line change
Expand Up @@ -1378,4 +1378,11 @@ struct CompatibilityFlags @0x8f8c1b68151b6cef {
# When enabled, the UTF-16le TextDecoder will replace lone surrogates with U+FFFD
# (the Unicode replacement character) as required by the spec. Previously, lone
# surrogates were passed through unchanged, producing non-well-formed strings.

unhandledRejectionAfterMicrotaskCheckpoint @160 :Bool
$compatEnableFlag("unhandled_rejection_after_microtask_checkpoint")
$compatDisableFlag("no_unhandled_rejection_after_microtask_checkpoint")
$compatEnableDate("2026-03-03");
# When enabled, unhandledrejection processing is deferred until the microtask
# checkpoint completes, avoiding misfires on multi-tick promise chains.
}
19 changes: 19 additions & 0 deletions src/workerd/jsg/jsg.c++
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,25 @@ bool Lock::v8HasOwn(v8::Local<v8::Object> obj, kj::StringPtr name) {

void Lock::runMicrotasks() {
v8Isolate->PerformMicrotaskCheckpoint();

auto& isolate = IsolateBase::from(v8Isolate);
// We only expect at most a handful of extra checkpoints. Keep a generous cap
// to flush cascaded microtasks but prevent a potential busy loop.
static constexpr uint MAX_EXTRA_MICROTASK_CHECKPOINTS = 64;
for (uint i = 0; i < MAX_EXTRA_MICROTASK_CHECKPOINTS; ++i) {
if (!isolate.takeExtraMicrotaskCheckpointRequested({})) {
return;
}
v8Isolate->PerformMicrotaskCheckpoint();
}

if (isolate.takeExtraMicrotaskCheckpointRequested({})) {
KJ_LOG(WARNING, "extra microtask checkpoint limit reached", MAX_EXTRA_MICROTASK_CHECKPOINTS);
}
}

void Lock::requestExtraMicrotaskCheckpoint() {
IsolateBase::from(v8Isolate).requestExtraMicrotaskCheckpoint({});
}

void Lock::terminateNextExecution() {
Expand Down
3 changes: 3 additions & 0 deletions src/workerd/jsg/jsg.h
Original file line number Diff line number Diff line change
Expand Up @@ -2800,6 +2800,9 @@ class Lock {

void runMicrotasks();

// Request an extra microtask checkpoint after the current one completes.
void requestExtraMicrotaskCheckpoint();

// Sets the terminate-execution flag on the isolate so that the next time code tries to run, it
// will be terminated. (But note that V8 only checks the flag at certain times, so it's possible
// some code will actually execute before termination kicks in.)
Expand Down
Loading
Loading