Skip to content
This repository was archived by the owner on Mar 13, 2025. It is now read-only.

Commit b43c0bc

Browse files
committed
Don't crash on unhandled promise rejections with the CLI, closes #115
...instead, log them. Adds a new `logUnhandledRejections` option. If a user has an `unhandledrejection` event listener in their worker, the rejection will only be logged if the user doesn't call `preventDefault()` on the event.
1 parent ca799fa commit b43c0bc

File tree

8 files changed

+156
-78
lines changed

8 files changed

+156
-78
lines changed

packages/core/src/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -624,7 +624,8 @@ export class MiniflareCore<
624624
const setupWatch = this.#setupWatch!.get(name);
625625
if (setupWatch) addAll(newWatchPaths, setupWatch);
626626
}
627-
const { modules, processedModuleRules } = this.#instances!.CorePlugin;
627+
const { modules, processedModuleRules, logUnhandledRejections } =
628+
this.#instances!.CorePlugin;
628629

629630
// Clean up process-wide promise rejection event listeners
630631
this.#globalScope?.[kDispose]();
@@ -633,7 +634,8 @@ export class MiniflareCore<
633634
this.#ctx.log,
634635
globals,
635636
bindings,
636-
modules
637+
modules,
638+
logUnhandledRejections
637639
);
638640
this.#globalScope = globalScope;
639641
this.#bindings = bindings;

packages/core/src/plugins/core.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export interface CoreOptions {
9696
// Replaced in MiniflareCoreOptions with something plugins-specific
9797
mounts?: Record<string, string | CoreOptions | BindingsOptions>;
9898
routes?: string[];
99+
logUnhandledRejections?: boolean;
99100
}
100101

101102
function mapMountEntries([name, pathEnv]: [string, string]): [
@@ -291,6 +292,9 @@ export class CorePlugin extends Plugin<CoreOptions> implements CoreOptions {
291292
})
292293
routes?: string[];
293294

295+
@Option({ type: OptionType.NONE })
296+
logUnhandledRejections?: boolean;
297+
294298
readonly processedModuleRules: ProcessedModuleRule[] = [];
295299

296300
readonly upstreamURL?: URL;

packages/core/src/standards/event.ts

Lines changed: 82 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
ThrowingEventTarget,
77
TypedEventListener,
88
ValueOf,
9+
prefixError,
910
} from "@miniflare/shared";
1011
import { Response as BaseResponse, fetch as baseFetch } from "undici";
1112
import { DOMException } from "./domexception";
@@ -178,18 +179,33 @@ export type WorkerGlobalScopeEventMap = {
178179

179180
export class WorkerGlobalScope extends ThrowingEventTarget<WorkerGlobalScopeEventMap> {}
180181

182+
// true will be added to this set if #logUnhandledRejections is true so we
183+
// don't remove the listener on removeEventListener, and know to dispose it.
184+
type PromiseListenerSetMember =
185+
| TypedEventListener<PromiseRejectionEvent>
186+
| boolean;
187+
188+
type PromiseListener =
189+
| {
190+
name: "unhandledRejection";
191+
set: Set<PromiseListenerSetMember>;
192+
listener: (reason: any, promise: Promise<any>) => void;
193+
}
194+
| {
195+
name: "rejectionHandled";
196+
set: Set<PromiseListenerSetMember>;
197+
listener: (promise: Promise<any>) => void;
198+
};
199+
181200
export class ServiceWorkerGlobalScope extends WorkerGlobalScope {
182201
readonly #log: Log;
183202
readonly #bindings: Context;
184203
readonly #modules?: boolean;
204+
readonly #logUnhandledRejections?: boolean;
185205
#calledAddFetchEventListener = false;
186206

187-
readonly #rejectionHandledListeners = new Set<
188-
TypedEventListener<PromiseRejectionEvent>
189-
>();
190-
readonly #unhandledRejectionListeners = new Set<
191-
TypedEventListener<PromiseRejectionEvent>
192-
>();
207+
readonly #unhandledRejection: PromiseListener;
208+
readonly #rejectionHandled: PromiseListener;
193209

194210
// Global self-references
195211
readonly global = this;
@@ -199,12 +215,29 @@ export class ServiceWorkerGlobalScope extends WorkerGlobalScope {
199215
log: Log,
200216
globals: Context,
201217
bindings: Context,
202-
modules?: boolean
218+
modules?: boolean,
219+
logUnhandledRejections?: boolean
203220
) {
204221
super();
205222
this.#log = log;
206223
this.#bindings = bindings;
207224
this.#modules = modules;
225+
this.#logUnhandledRejections = logUnhandledRejections;
226+
227+
this.#unhandledRejection = {
228+
name: "unhandledRejection",
229+
set: new Set(),
230+
listener: this.#unhandledRejectionListener,
231+
};
232+
this.#rejectionHandled = {
233+
name: "rejectionHandled",
234+
set: new Set(),
235+
listener: this.#rejectionHandledListener,
236+
};
237+
// If we're logging unhandled rejections, register the process-wide listener
238+
if (this.#logUnhandledRejections) {
239+
this.#maybeAddPromiseListener(this.#unhandledRejection, true);
240+
}
208241

209242
// Only including bindings in global scope if not using modules
210243
Object.assign(this, globals);
@@ -248,24 +281,10 @@ export class ServiceWorkerGlobalScope extends WorkerGlobalScope {
248281
// Register process wide unhandledRejection/rejectionHandled listeners if
249282
// not already done so
250283
if (type === "unhandledrejection" && listener) {
251-
if (this.#unhandledRejectionListeners.size === 0) {
252-
this.#log.verbose("Adding process unhandledRejection listener...");
253-
process.prependListener(
254-
"unhandledRejection",
255-
this.#unhandledRejectionListener
256-
);
257-
}
258-
this.#unhandledRejectionListeners.add(listener as any);
284+
this.#maybeAddPromiseListener(this.#unhandledRejection, listener);
259285
}
260286
if (type === "rejectionhandled" && listener) {
261-
if (this.#rejectionHandledListeners.size === 0) {
262-
this.#log.verbose("Adding process rejectionHandled listener...");
263-
process.prependListener(
264-
"rejectionHandled",
265-
this.#rejectionHandledListener
266-
);
267-
}
268-
this.#rejectionHandledListeners.add(listener as any);
287+
this.#maybeAddPromiseListener(this.#rejectionHandled, listener);
269288
}
270289

271290
super.addEventListener(type, listener, options);
@@ -286,26 +305,10 @@ export class ServiceWorkerGlobalScope extends WorkerGlobalScope {
286305
// Unregister process wide rejectionHandled/unhandledRejection listeners if
287306
// no longer needed and not already done so
288307
if (type === "unhandledrejection" && listener) {
289-
const registered = this.#unhandledRejectionListeners.size > 0;
290-
this.#unhandledRejectionListeners.delete(listener as any);
291-
if (registered && this.#unhandledRejectionListeners.size === 0) {
292-
this.#log.verbose("Removing process unhandledRejection listener...");
293-
process.removeListener(
294-
"unhandledRejection",
295-
this.#unhandledRejectionListener
296-
);
297-
}
308+
this.#maybeRemovePromiseListener(this.#unhandledRejection, listener);
298309
}
299310
if (type === "rejectionhandled" && listener) {
300-
const registered = this.#rejectionHandledListeners.size > 0;
301-
this.#rejectionHandledListeners.delete(listener as any);
302-
if (registered && this.#rejectionHandledListeners.size === 0) {
303-
this.#log.verbose("Removing process rejectionHandled listener...");
304-
process.removeListener(
305-
"rejectionHandled",
306-
this.#rejectionHandledListener
307-
);
308-
}
311+
this.#maybeRemovePromiseListener(this.#rejectionHandled, listener);
309312
}
310313

311314
super.removeEventListener(type, listener, options);
@@ -423,22 +426,50 @@ export class ServiceWorkerGlobalScope extends WorkerGlobalScope {
423426
return (await Promise.all(event[kWaitUntil])) as WaitUntil;
424427
}
425428

429+
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
430+
#maybeAddPromiseListener(listener: PromiseListener, member: any): void {
431+
if (listener.set.size === 0) {
432+
this.#log.verbose(`Adding process ${listener.name} listener...`);
433+
process.prependListener(listener.name as any, listener.listener as any);
434+
}
435+
listener.set.add(member);
436+
}
437+
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
438+
#maybeRemovePromiseListener(listener: PromiseListener, member: any): void {
439+
const registered = listener.set.size > 0;
440+
listener.set.delete(member);
441+
if (registered && listener.set.size === 0) {
442+
this.#log.verbose(`Removing process ${listener.name} listener...`);
443+
process.removeListener(listener.name, listener.listener);
444+
}
445+
}
446+
#resetPromiseListener(listener: PromiseListener): void {
447+
if (listener.set.size > 0) {
448+
this.#log.verbose(`Removing process ${listener.name} listener...`);
449+
process.removeListener(listener.name, listener.listener);
450+
}
451+
listener.set.clear();
452+
}
453+
426454
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
427455
#unhandledRejectionListener = (reason: any, promise: Promise<any>): void => {
428456
const event = new PromiseRejectionEvent("unhandledrejection", {
429457
reason,
430458
promise,
431459
});
432460
const notCancelled = super.dispatchEvent(event);
433-
// If the event wasn't preventDefault()ed, remove the listener and cause
434-
// an unhandled promise rejection again. This should terminate the program.
461+
// If the event wasn't preventDefault()ed,
435462
if (notCancelled) {
436-
process.removeListener(
437-
"unhandledRejection",
438-
this.#unhandledRejectionListener
439-
);
440-
// noinspection JSIgnoredPromiseFromCall
441-
Promise.reject(reason);
463+
if (this.#logUnhandledRejections) {
464+
// log if we're logging unhandled rejections
465+
this.#log.error(prefixError("Unhandled Promise Rejection", reason));
466+
} else {
467+
// ...otherwise, remove the listener and cause an unhandled promise
468+
// rejection again. This should terminate the program.
469+
this.#resetPromiseListener(this.#unhandledRejection);
470+
// noinspection JSIgnoredPromiseFromCall
471+
Promise.reject(reason);
472+
}
442473
}
443474
};
444475

@@ -449,22 +480,7 @@ export class ServiceWorkerGlobalScope extends WorkerGlobalScope {
449480
};
450481

451482
[kDispose](): void {
452-
if (this.#unhandledRejectionListeners.size > 0) {
453-
this.#log.verbose("Removing process unhandledRejection listener...");
454-
process.removeListener(
455-
"unhandledRejection",
456-
this.#unhandledRejectionListener
457-
);
458-
}
459-
this.#unhandledRejectionListeners.clear();
460-
461-
if (this.#rejectionHandledListeners.size > 0) {
462-
this.#log.verbose("Removing process rejectionHandled listener...");
463-
process.removeListener(
464-
"rejectionHandled",
465-
this.#rejectionHandledListener
466-
);
467-
}
468-
this.#rejectionHandledListeners.clear();
483+
this.#resetPromiseListener(this.#unhandledRejection);
484+
this.#resetPromiseListener(this.#rejectionHandled);
469485
}
470486
}

packages/core/test/plugins/core.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ test("CorePlugin: parses options from wrangler config", (t) => {
187187
"http://localhost:8787/*",
188188
"miniflare.mf:8787/*",
189189
],
190+
logUnhandledRejections: undefined,
190191
});
191192
// Check build upload dir defaults to dist
192193
options = parsePluginWranglerConfig(

packages/core/test/standards/event.promise.spec.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { setTimeout } from "timers/promises";
12
import {
23
PromiseRejectionEvent,
34
ServiceWorkerGlobalScope,
@@ -176,3 +177,48 @@ test.serial(
176177
t.is(event.promise, promise);
177178
}
178179
);
180+
181+
// Test logUnhandledRejections option
182+
test.serial(
183+
"ServiceWorkerGlobalScope: logs unhandled rejections",
184+
async (t) => {
185+
const log = new TestLog();
186+
const [logTrigger, logPromise] = triggerPromise<Error>();
187+
log.error = logTrigger;
188+
new ServiceWorkerGlobalScope(log, {}, {}, false, true);
189+
190+
const error = new Error("Oops, did I do that?");
191+
// noinspection ES6MissingAwait
192+
Promise.reject(error);
193+
194+
const event = await logPromise;
195+
t.regex(
196+
event.stack!,
197+
/^Unhandled Promise Rejection: Error: Oops, did I do that\?/
198+
);
199+
}
200+
);
201+
test.serial(
202+
"ServiceWorkerGlobalScope: doesn't log unhandled rejections if preventDefault() called",
203+
async (t) => {
204+
const log = new TestLog();
205+
const globalScope = new ServiceWorkerGlobalScope(log, {}, {}, false, true);
206+
207+
const [eventTrigger, eventPromise] =
208+
triggerPromise<PromiseRejectionEvent>();
209+
globalScope.addEventListener("unhandledrejection", (e) => {
210+
e.preventDefault();
211+
eventTrigger(e);
212+
});
213+
214+
const error = new Error("Oops, did I do that?");
215+
// noinspection ES6MissingAwait
216+
const promise = Promise.reject(error);
217+
218+
const event = await eventPromise;
219+
t.is(event.promise, promise);
220+
221+
await setTimeout();
222+
t.is(log.logsAtLevel(LogLevel.ERROR).length, 0);
223+
}
224+
);

packages/http-server/src/index.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
Response,
1515
logResponse,
1616
} from "@miniflare/core";
17-
import { randomHex } from "@miniflare/shared";
17+
import { prefixError, randomHex } from "@miniflare/shared";
1818
import { coupleWebSocket } from "@miniflare/web-sockets";
1919
import { BodyInit, Headers } from "undici";
2020
import { getAccessibleHosts } from "./helpers";
@@ -307,15 +307,7 @@ export function createRequestListener<Plugins extends HTTPPluginSignatures>(
307307
}
308308

309309
// Add method and URL to stack trace
310-
const proxiedError = new Proxy(e, {
311-
get(target, propertyKey, receiver) {
312-
const value = Reflect.get(target, propertyKey, receiver);
313-
return propertyKey === "stack"
314-
? `${req.method} ${req.url}: ${value}`
315-
: value;
316-
},
317-
});
318-
mf.log.error(proxiedError);
310+
mf.log.error(prefixError(`${req.method} ${req.url}`, e));
319311
}
320312
}
321313

packages/miniflare/src/cli.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,12 @@ async function main() {
8282
? LogLevel.DEBUG
8383
: LogLevel.INFO;
8484
const mfOptions = options as MiniflareOptions;
85+
8586
mfOptions.log = new Log(logLevel);
8687
mfOptions.sourceMap = true;
88+
// Catch and log unhandled rejections as opposed to crashing
89+
mfOptions.logUnhandledRejections = true;
90+
8791
const mf = new Miniflare(mfOptions);
8892
try {
8993
// Start Miniflare development server

packages/shared/src/error.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,16 @@ export abstract class MiniflareError<
99
this.name = `${new.target.name} [${code}]`;
1010
}
1111
}
12+
13+
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
14+
export function prefixError(prefix: string, e: any): Error {
15+
if (e.stack) {
16+
return new Proxy(e, {
17+
get(target, propertyKey, receiver) {
18+
const value = Reflect.get(target, propertyKey, receiver);
19+
return propertyKey === "stack" ? `${prefix}: ${value}` : value;
20+
},
21+
});
22+
}
23+
return e;
24+
}

0 commit comments

Comments
 (0)