Skip to content
File

Blob: src/workerd/api/tests/unhandled-rejection-test.js

javascript227 lines
1// Copyright (c) 2026 Cloudflare, Inc.
2// Licensed under the Apache 2.0 license found in the LICENSE file or at:
3// https://opensource.org/licenses/Apache-2.0
4// Regression tests for https://github.com/cloudflare/workerd/issues/6020
5// Unhandled rejection should NOT fire for promises that are handled through
6// multi-tick promise chains.
7 
8import { strictEqual, ok, rejects } from 'node:assert';
9import { mock } from 'node:test';
10 
11const asyncFunction = async (name) => {
12 throw new Error(`this function rejects: ${name}`);
13};
14 
15// Verifies assert.rejects handles rejections without unhandledrejection.
16export const assertRejects = {
17 async test() {
18 const handler = mock.fn();
19 addEventListener('unhandledrejection', handler);
20 try {
21 await rejects(async () => asyncFunction('A'));
22 strictEqual(
23 handler.mock.callCount(),
24 0,
25 'unhandledrejection should not fire for assert.rejects'
26 );
27 } finally {
28 removeEventListener('unhandledrejection', handler);
29 }
30 },
31};
32 
33// Verifies chained .then().catch() handling avoids unhandledrejection.
34export const promiseChainCatch = {
35 async test() {
36 const handler = mock.fn();
37 addEventListener('unhandledrejection', handler);
38 try {
39 const error = await Promise.resolve()
40 .then(() => asyncFunction('B'))
41 .then(() => null)
42 .catch((e) => e);
43 ok(error instanceof Error);
44 strictEqual(error.message, 'this function rejects: B');
45 strictEqual(
46 handler.mock.callCount(),
47 0,
48 'unhandledrejection should not fire for .catch() chain'
49 );
50 } finally {
51 removeEventListener('unhandledrejection', handler);
52 }
53 },
54};
55 
56// Verifies try/catch around awaited chain avoids unhandledrejection.
57export const tryCatchAwait = {
58 async test() {
59 const handler = mock.fn();
60 addEventListener('unhandledrejection', handler);
61 try {
62 try {
63 await Promise.resolve('C').then(asyncFunction);
64 } catch (error) {
65 ok(error instanceof Error);
66 strictEqual(error.message, 'this function rejects: C');
67 }
68 strictEqual(
69 handler.mock.callCount(),
70 0,
71 'unhandledrejection should not fire for try/catch'
72 );
73 } finally {
74 removeEventListener('unhandledrejection', handler);
75 }
76 },
77};
78 
79// Verifies a truly unhandled rejection still emits unhandledrejection.
80export const genuineUnhandledRejectionStillFires = {
81 async test() {
82 const { promise, resolve } = Promise.withResolvers();
83 const handler = mock.fn(() => resolve());
84 addEventListener('unhandledrejection', handler, { once: true });
85 Promise.reject('boom');
86 await promise;
87 strictEqual(
88 handler.mock.callCount(),
89 1,
90 'unhandledrejection should fire for genuinely unhandled rejection'
91 );
92 },
93};
94 
95// Verifies unhandledrejection fires after a Promise.resolve tick.
96export const unhandledRejectionAfterPromiseResolve = {
97 async test() {
98 const { promise, resolve } = Promise.withResolvers();
99 const handler = mock.fn(() => resolve());
100 addEventListener('unhandledrejection', handler, { once: true });
101 Promise.reject('boom');
102 await Promise.resolve();
103 await promise;
104 strictEqual(
105 handler.mock.callCount(),
106 1,
107 'unhandledrejection should fire after Promise.resolve'
108 );
109 },
110};
111 
112// Verifies unhandledrejection followed by rejectionhandled on late catch.
113export const lateHandlerTriggersRejectionhandled = {
114 async test() {
115 const { promise: unhandledPromise, resolve: resolveUnhandled } =
116 Promise.withResolvers();
117 const { promise: handledPromise, resolve: resolveHandled } =
118 Promise.withResolvers();
119 let unhandledReason;
120 let handledReason;
121 const unhandledHandler = mock.fn((event) => {
122 unhandledReason = event.reason;
123 resolveUnhandled();
124 });
125 const handledHandler = mock.fn((event) => {
126 handledReason = event.reason;
127 resolveHandled();
128 });
129 addEventListener('unhandledrejection', unhandledHandler, { once: true });
130 addEventListener('rejectionhandled', handledHandler, { once: true });
131 try {
132 const error = new Error('late');
133 const promise = Promise.reject(error);
134 await unhandledPromise;
135 promise.catch(() => {});
136 await handledPromise;
137 strictEqual(
138 unhandledHandler.mock.callCount(),
139 1,
140 'unhandledrejection should fire once before late handler'
141 );
142 strictEqual(
143 handledHandler.mock.callCount(),
144 1,
145 'rejectionhandled should fire after late handler'
146 );
147 ok(unhandledReason instanceof Error);
148 strictEqual(unhandledReason.message, 'late');
149 strictEqual(
150 handledReason,
151 undefined,
152 'rejectionhandled reason should be undefined'
153 );
154 } finally {
155 removeEventListener('unhandledrejection', unhandledHandler);
156 removeEventListener('rejectionhandled', handledHandler);
157 }
158 },
159};
160 
161// Verifies unhandledrejection handler can trigger another unhandled rejection.
162export const handlerTriggeredUnhandledRejection = {
163 async test() {
164 const { promise, resolve } = Promise.withResolvers();
165 const timeout = new Promise((resolveTimeout) => {
166 setTimeout(resolveTimeout, 25);
167 });
168 const reasons = [];
169 let callCount = 0;
170 const handler = mock.fn((event) => {
171 reasons.push(event.reason);
172 callCount += 1;
173 if (callCount === 1) {
174 queueMicrotask(() => Promise.reject(new Error('second')));
175 }
176 if (callCount === 2) {
177 resolve();
178 }
179 });
180 addEventListener('unhandledrejection', handler);
181 try {
182 Promise.reject(new Error('first'));
183 await Promise.race([promise, timeout]);
184 strictEqual(
185 handler.mock.callCount(),
186 2,
187 'unhandledrejection should fire for rejection triggered by handler'
188 );
189 strictEqual(reasons.length, 2);
190 ok(reasons[0] instanceof Error);
191 strictEqual(reasons[0].message, 'first');
192 ok(reasons[1] instanceof Error);
193 strictEqual(reasons[1].message, 'second');
194 } finally {
195 removeEventListener('unhandledrejection', handler);
196 }
197 },
198};
199 
200// Verifies each unhandled rejection emits its own event.
201export const multipleUnhandledRejections = {
202 async test() {
203 const { promise, resolve } = Promise.withResolvers();
204 const timeout = new Promise((resolveTimeout) => {
205 setTimeout(resolveTimeout, 25);
206 });
207 const handler = mock.fn(() => {
208 if (handler.mock.callCount() === 2) {
209 resolve();
210 }
211 });
212 addEventListener('unhandledrejection', handler);
213 try {
214 Promise.reject(new Error('one'));
215 Promise.reject(new Error('two'));
216 await Promise.race([promise, timeout]);
217 strictEqual(
218 handler.mock.callCount(),
219 2,
220 'unhandledrejection should fire for each unhandled rejection'
221 );
222 } finally {
223 removeEventListener('unhandledrejection', handler);
224 }
225 },
226};