Report unhandled rejections in programs without async work (#2479)

This commit is contained in:
Chris Tate
2026-10-28 14:13:38 -05:00
committed by GitHub
7 changed files with 32 additions and 3 deletions
@@ -103,6 +103,7 @@ export interface ChunkResult {
readonly flags: {
readonly usesCoroutines: boolean;
readonly usesTimers: boolean;
readonly usesPromises: boolean;
readonly usesGenSlots: boolean;
readonly needsUnionNone: boolean;
readonly needsNullableAbsent: boolean;
@@ -603,14 +603,16 @@ export function emitModuleParts(emitter: LlEmitter): string[] {
const globalReleases = exitReleaseLines("g");
const asyncEntry = emitter.fnByName.get(emitter.mod.entry)?.async === true;
const entryMayThrow = emitter.mayThrow.has(emitter.mod.entry);
// The event loop runs when timers appeared OR any async/generator
// function exists (the C main's hasAsync || hasGenerators ||
// usesTimers gate). Generator programs run
// The event loop runs when timers appeared, a promise is made (the loop's
// end reports an unhandled rejection, Node's exit 1), OR any
// async/generator function exists (the C main's hasAsync ||
// hasGenerators || usesTimers gate). Generator programs run
// the loop too: its exit accounting notes still-suspended generator
// fibers as abandoned, so the RC audit downgrades exactly like the
// async loop-exhaustion story.
const runsLoop =
emitter.usesTimers ||
emitter.usesPromises ||
emitter.ffiHasForeignCallback ||
emitter.mod.functions.some((f) => f.async === true || f.generator !== undefined);
const uncaughtReleases = entryMayThrow && !asyncEntry ? exitReleaseLines("gu") : [];
@@ -1635,6 +1635,10 @@ export class LlEmitter {
/** setTimeout and friends appeared somewhere: main must run the event
* loop even in programs with no async functions. */
usesTimers = false;
/** A promise is made somewhere (Promise statics, `new Promise`,
* Promise.withResolvers): main runs the event loop, whose end reports an
* unhandled rejection as Node does, even without async functions. */
usesPromises = false;
/** The program calls mock.timers members (moduleUsesMockTimers): timer
* calls emit scr_test.c's mock-aware entry points. */
readonly mockTimers: boolean;
@@ -5717,6 +5721,7 @@ export class LlEmitter {
flags: {
usesCoroutines: this.usesCoroutines,
usesTimers: this.usesTimers,
usesPromises: this.usesPromises,
usesGenSlots: this.usesGenSlots,
needsUnionNone: this.needsUnionNone,
needsNullableAbsent: this.needsNullableAbsent,
@@ -5754,6 +5759,7 @@ export class LlEmitter {
const flags = chunk.flags;
this.usesCoroutines ||= flags.usesCoroutines;
this.usesTimers ||= flags.usesTimers;
this.usesPromises ||= flags.usesPromises;
this.usesGenSlots ||= flags.usesGenSlots;
this.needsUnionNone ||= flags.needsUnionNone;
this.needsNullableAbsent ||= flags.needsNullableAbsent;
@@ -100,6 +100,7 @@ export function emitExpr(host: LlvmEmitterContext, e: IrExpr): LlValue {
case "caughtToDyn":
return host.emitDynamicExpr(e);
case "intrinsic":
if (e.name.startsWith("promise.")) host.usesPromises = true;
return host.emitIntrinsicExpr(e);
case "jsonStringify":
case "dynCheck":
@@ -108,8 +109,10 @@ export function emitExpr(host: LlvmEmitterContext, e: IrExpr): LlValue {
case "genResume":
case "awaitExpr":
case "awaitUnionExpr":
return host.emitAsyncExpr(e);
case "newPromise":
case "promiseWithResolvers":
host.usesPromises = true;
return host.emitAsyncExpr(e);
case "libCall":
return host.emitLibCall(e);
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/unhandled-rejection-without-async.cjs"
],
"diags": []
}
@@ -0,0 +1,10 @@
// @exit: 1
'use strict';
// A program with no async functions or timers still ends Node's way when a
// promise rejection goes unhandled: after the script, the 'exit' listeners
// see code 1 and the process exits 1. Settled promises alone do not fail.
process.on('exit', (code) => console.log('exit listener', code));
const settled = Promise.resolve(1);
new Promise((resolve) => resolve(2));
console.log('end of script', typeof settled);
new Promise((resolve, reject) => reject(new TypeError('rejected in the executor')));
@@ -0,0 +1 @@
{ "plain": 2511, "san": 3503 }