Skip to content
Draft
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
26 changes: 14 additions & 12 deletions src/js/internal/fs/streams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,16 @@ function emitDrain(stream: FSStream) {
}
}

// The sink rejects for a failed write. Node reports one on a tick (Writable's onwriteError), and
// no tick runs once 'exit' is being emitted: a write that fails there has no callback and no 'error'.
function reportSinkError(cb: ((err: any) => void) | undefined, err: any, stream?: FSStream) {
if (process._exiting) return;
Comment thread
robobun marked this conversation as resolved.
Resolved
if (cb) cb(err);
// Node.js onwriteError: callback AND destroy are both invoked; the
// callback is additive, not a replacement for the 'error' event.
if (stream) require("internal/streams/destroy").errorOrDestroy(stream, err);
}

function _write(data, encoding, cb) {
const fileSink = this[kWriteStreamFastPath];

Expand All @@ -578,7 +588,7 @@ function _write(data, encoding, cb) {
emitDrain(this);
cb(null);
})
.catch(cb);
.catch(err => reportSinkError(cb, err));
return false; // Indicate backpressure
} else {
cb(null);
Expand Down Expand Up @@ -623,10 +633,7 @@ function underscoreWriteFast(this: FSStream, data: any, encoding: any, cb: any)
if (cb) cb(null);
emitDrain(this);
},
err => {
if (cb) cb(err);
require("internal/streams/destroy").errorOrDestroy(this, err);
},
err => reportSinkError(cb, err, this),
);
return false;
} else {
Expand Down Expand Up @@ -691,12 +698,7 @@ function writeFast(this: FSStream, data: any, encoding: any, cb: any) {
emitDrain(this);
cb(null);
},
err => {
cb(err);
// Node.js onwriteError: callback AND destroy are both invoked; the
// callback is additive, not a replacement for the 'error' event.
require("internal/streams/destroy").errorOrDestroy(this, err);
},
err => reportSinkError(cb, err, this),
);
return false; // Indicate backpressure
} else {
Expand Down Expand Up @@ -734,7 +736,7 @@ writeStreamPrototype._writev = function (data, cb) {
emitDrain(this);
cb(null);
})
.catch(cb);
.catch(err => reportSinkError(cb, err));
return false;
} else {
cb(null);
Expand Down
75 changes: 68 additions & 7 deletions src/jsc/VirtualMachine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,9 @@ pub struct VirtualMachine {
pub on_print_error_zig_exception: Option<fn(*mut c_void, &ZigException)>,
pub on_print_error_zig_exception_ctx: *mut c_void,
pub(crate) is_handling_uncaught_exception: bool,
/// Advances on each error `uncaught_exception` takes, handled or not. `on_exit` compares it
/// around the 'exit' listeners: an error they threw ends the event.
uncaught_exception_epoch: u32,
pub(crate) exit_on_uncaught_exception: bool,
/// An earlier `--hot` run ended with an unhandled error. Its `beforeExit` listeners are still registered.
hot_run_failed: bool,
Expand Down Expand Up @@ -500,6 +503,8 @@ unsafe extern "C" {

safe fn Process__dispatchOnBeforeExit(global: &JSGlobalObject, code: u8);
safe fn Process__dispatchOnExit(global: &JSGlobalObject, code: u8);
safe fn Bun__Process__hasExitCode(global: &JSGlobalObject) -> bool;
safe fn Bun__Process__discardNextTicks(global: &JSGlobalObject);
safe fn Bun__closeAllSQLiteDatabasesForTermination(global: &JSGlobalObject);
safe fn Bun__closeAllNodeSqliteDatabasesForTermination(global: &JSGlobalObject);
safe fn Bun__WebView__closeAllForTermination();
Expand Down Expand Up @@ -776,6 +781,8 @@ pub struct ExitHandler {
/// `process.exit()`, a fatal error or the end of a `bun test` run, as opposed to the event loop running dry.
/// See `VirtualMachine::exit_tears_down_napi_envs`.
pub requested: bool,
/// `on_before_exit()` returned with the loop dry and no unhandled error.
pub loop_ran_dry: bool,
}

impl ExitHandler {
Expand Down Expand Up @@ -823,14 +830,22 @@ impl ExitHandler {
/// parent via `container_of` would escape the provenance of `&mut self`
/// (which only covers the `ExitHandler` field). Callers pass the VM
/// reference instead; the body re-enters JS so no `&mut` is held.
pub(crate) fn dispatch_on_exit(vm: &VirtualMachine) {
let exit_code = vm.exit_handler.exit_code;
///
/// Returns whether the event was emitted and nothing stopped its listeners.
pub(crate) fn dispatch_on_exit(vm: &VirtualMachine) -> bool {
// `process.on('exit')` handlers are user script (see `on_exit`).
if vm.script_allowed() && !vm.exit_handler.skip_exit_listeners {
Process__dispatchOnExit(vm.global(), exit_code);
if !vm.script_allowed() || vm.exit_handler.skip_exit_listeners {
return false;
}
if vm.worker.is_none() {
Bun__WebView__closeAllForTermination();
let exit_code = vm.exit_handler.exit_code;
let global = vm.global();
match jsc::from_js_host_call_generic(global, || Process__dispatchOnExit(global, exit_code))
{
Ok(()) => true,
Err(err) => {
let _ = crate::task::report_error_or_terminate(global, err);
false
}
}
}

Expand Down Expand Up @@ -2145,6 +2160,7 @@ impl VirtualMachine {
if self.is_shutting_down() || !self.script_allowed() || err.is_termination_exception() {
return true;
}
self.uncaught_exception_epoch = self.uncaught_exception_epoch.wrapping_add(1);

// An exception thrown by a Bun.ModuleGraph's module code is that graph's to
// handle, ahead of the test runner and the thread-wide path. (A rejection
Expand Down Expand Up @@ -2227,6 +2243,7 @@ impl VirtualMachine {
}

pub fn on_before_exit(&mut self) {
self.exit_handler.loop_ran_dry = false;
// Node only emits 'beforeExit' on a natural drain; a fatal uncaught
// exception that brought us here is an implicit process.exit(1). Arm
// the hard-exit flag ourselves since we're skipping the dispatch that
Expand Down Expand Up @@ -2269,6 +2286,7 @@ impl VirtualMachine {

break;
}
self.exit_handler.loop_ran_dry = self.unhandled_error_counter == 0;
}

/// Writes each configured profile once. The sampling report is written first because
Expand Down Expand Up @@ -2327,7 +2345,22 @@ impl VirtualMachine {
// Write profiles before any shutdown begins.
self.write_profiles();

ExitHandler::dispatch_on_exit(self);
// The loop ran dry: node emits this 'exit' through MakeCallback, which then runs the
// listeners' promise jobs and reports what they rejected, unless a listener threw.
// `process.exit()` and a fatal error emit it with a plain call. Not in a worker:
// `WebWorker::shutdown` has unpublished its VM, so `terminate()` could not stop a job.
let ran_dry =
self.is_main_thread() && self.exit_handler.loop_ran_dry && !self.exit_handler.requested;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 (optional) Under bun test with BUN_TEST_DRAIN_EVENT_LOOP=1, a node:test file whose last loop turn left an unhandled rejection still gets the post-'exit' jobs drain and rejection pass, and if that pass reports anything the Node-API cleanup hooks are skipped. src/jsc/VirtualMachine.rs:2353 computes ran_dry from loop_ran_dry, which on_before_exit set at src/runtime/cli/test_command.rs:3070 before the rejection pass at test_command.rs:3075 bumped unhandled_error_counter, and requested stays false via exit_is_requested(). Fix: treat a loop that ended with unhandled_error_counter > 0 as not dry at on_exit time (re-check the counter at 2353 or set requested when the counter is nonzero, as run_command.rs:1711 does), so every entry point agrees.

Why this was flagged

Trigger: bun test on a node:test file with BUN_TEST_DRAIN_EVENT_LOOP=1, where the file's final turn rejects a promise nobody handles. test_command.rs:3070 calls on_before_exit, which stores loop_ran_dry = (unhandled_error_counter == 0) at src/jsc/VirtualMachine.rs:2289 while the counter is still 0. test_command.rs:3075 then runs handle_rejected_promises, which under isBunTest increments the counter at VirtualMachine.rs:4558. test_command.rs:2739 sets requested = exit_is_requested() = false in this mode. on_exit at VirtualMachine.rs:2353 therefore sees ran_dry = true although the loop ended with a reported error, which the run_command.rs:1711 path explicitly treats as a fatal (non-dry) exit. Lines 2407-2412 then run the 'exit' listeners' jobs and a second rejection pass; a new report sets requested = true at 2417, which makes exit_tears_down_napi_envs() false so run_cleanup_hooks is skipped — on the base branch this file ran the addon cleanup hooks.

Verification: Trigger: bun test with BUN_TEST_DRAIN_EVENT_LOOP=1 on a file whose final turn leaves an unhandled rejection. test_command.rs:3070 calls vm.on_before_exit(), which stores loop_ran_dry while the counter is still 0; :3075 then bumps the counter. So on_exit computes ran_dry = true (VirtualMachine.rs:2353) and runs run_exit_listeners_jobs. Contrast run_command.rs:1709-1713, which sets requested = true.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed. on_exit will also require unhandled_error_counter == 0, so that a loop that ended with a reported error is not dry on any entry point. I built and tested it locally. It comes with the rebase push (the PR is a draft until then).

One observation for the test. Under bun test, main already runs the jobs of the 'exit' listeners after on_exit returns, and their errors are silent. So the test asserts what is reported, not what the job prints.

let uncaught_exception_epoch = self.uncaught_exception_epoch;
{
let _entered = ran_dry.then(|| self.enter_event_loop_scope_without_checkpoint());
if ExitHandler::dispatch_on_exit(self) && ran_dry {
self.run_exit_listeners_jobs(uncaught_exception_epoch);
}
}
if self.is_main_thread() {
Bun__WebView__closeAllForTermination();
}

// process.exit() never reaches drain_microtasks; flush AutoFlusher sinks here.
if !self.is_inside_deferred_task_queue.get() {
Expand Down Expand Up @@ -2360,6 +2393,34 @@ impl VirtualMachine {
}
}

/// What node's `InternalCallbackScope::Close` (src/api/callback.cc) runs after the 'exit'
/// listeners of a loop that ran dry, unless one threw: the microtask checkpoint, then the
/// rejection pass. `&self` and never inlined: the listeners and the jobs change the fields
/// it compares through other references.
#[cold]
#[inline(never)]
fn run_exit_listeners_jobs(&self, uncaught_exception_epoch: u32) {
if self.uncaught_exception_epoch != uncaught_exception_epoch {
Comment thread
robobun marked this conversation as resolved.
Resolved
return;
}
let global = self.global();
Bun__Process__discardNextTicks(global);
if self.event_loop_mut().drain_microtasks().is_err() {
return;
}
let unhandled_error_counter = self.unhandled_error_counter;
Comment on lines +2408 to +2411

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 (optional) Under bun test with BUN_TEST_DRAIN_EVENT_LOOP=1, an 'exit' listener job that throws is printed but the file still exits 0, while a job that rejects exits 1. run_exit_listeners_jobs snapshots unhandled_error_counter at src/jsc/VirtualMachine.rs:2411 only after drain_microtasks, so an error the checkpoint reported through the bun:test branch of uncaught_exception (counter bumped, no exit) never sets requested or the exit code. Fix: take the counter snapshot before Bun__Process__discardNextTicks and the drain, so every error reported by the checkpoint or the rejection pass marks the exit failed and sets code 1 unless process.exitCode is assigned. In bun run the same throw hard-exits 1 via exit_on_uncaught_exception, so only the bun:test path is affected.

Why this was flagged

A node:test file run as bun test ./x.test.js with BUN_TEST_DRAIN_EVENT_LOOP=1 containing process.on("exit", () => { queueMicrotask(() => { throw new Error("x") }) }); test("passes", () => {}). src/runtime/cli/test_command.rs:2739 leaves exit_handler.requested false in drain mode and on_exit (src/jsc/VirtualMachine.rs:2357) calls run_exit_listeners_jobs. drain_microtasks at :2408 runs the job; JSC reports the throw through uncaught_exception, where the isBunTest branch at :2172 bumps unhandled_error_counter, prints via jest::on_unhandled_rejection, and returns true without exiting. The snapshot at :2411 is taken after that, so the compare at :2413 sees no change: requested stays false and exit_code keeps the value computed at test_command.rs:2736. Base never runs the job (nothing printed, exit 0); this PR prints error: x and still exits 0, while the sibling rejection case exits 1 through the same function. The exit_on_uncaught_exception safeguard at :2209 does not apply because the isBunTest check at :2172 returns first.

Verification: nit — triggered when a node:test file run under bun test with BUN_TEST_DRAIN_EVENT_LOOP=1 has an 'exit' listener whose promise job throws. The isBunTest branch at src/jsc/VirtualMachine.rs:2172-2176 does unhandled_error_counter += 1 and returns without touching the exit code; the snapshot at :2411 is taken AFTER that bump, so neither requested nor exit_code = 1 is set. The error prints but the file exits 0; base already exits 0.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed. The helper will take its mark of unhandled_error_counter before it discards the ticks and runs the checkpoint. I built and tested it locally: a node:test file whose 'exit' listener leaves a job that throws then exits 1, as with a rejection.

It is not pushed yet. The PR is a draft until it can be rebased on #45575, #45597 and #45582, and this change comes with that push.

let _ = global.handle_rejected_promises();
Comment thread
robobun marked this conversation as resolved.
Resolved
if self.unhandled_error_counter != unhandled_error_counter {
// A fatal error in node: it exits with the assigned `process.exitCode` or with 1,
// and frees no environment.
let exit_handler = &mut self.as_mut().exit_handler;
exit_handler.requested = true;
if !Bun__Process__hasExitCode(global) {
exit_handler.exit_code = 1;
}
Comment on lines +2416 to +2420

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Under bun run, a script that assigns process.exitCode = N and has an 'exit' listener whose promise rejects now exits 1 instead of N (base: silent, exit N; Node: error printed, exit N). The new pass at src/jsc/VirtualMachine.rs:2412 reports the rejection through Run::on_unhandled_rejection_before_close, which sets ANY_UNHANDLED, and print_unhandled_version_note at src/runtime/cli/run_command.rs:1821 then stores exit_code = 1 after on_exit, so the Bun__Process__hasExitCode branch at :2418 is overridden for this entry point. Fix: keep the assigned code on every entry point that runs the new pass, e.g. have the post-on_exit store in run_command.rs respect Bun__Process__hasExitCode.

Why this was flagged

A CommonJS or ESM script run with bun file.js does process.exitCode = 2; process.on("exit", async () => { throw new Error("cleanup failed"); }) and lets its loop run dry. Run::start (src/runtime/cli/run_command.rs:1708) installs Run::on_unhandled_rejection_before_close and calls vm.on_exit() at :1714. on_exit computes ran_dry true (src/jsc/VirtualMachine.rs:2352) and run_exit_listeners_jobs drains the listener's job and runs handle_rejected_promises() at :2412; unhandled_rejection_owned bumps unhandled_error_counter and calls the handler at :4641-4642, which sets ANY_UNHANDLED at run_command.rs:1379. Back in run_exit_listeners_jobs, Bun__Process__hasExitCode is true, so exit_code stays 2 (:2418-2420). Then Run::start at :1716 sees ANY_UNHANDLED and print_unhandled_version_note at :1821 stores exit_code = 1 before global_exit(). The user gets exit status 1; on the base branch the rejection was never reported and the process exited 2; Node v26 prints the error and exits 2.

Verification: Then Run::start :1716-1717 sees ANY_UNHANDLED and calls print_unhandled_version_note, whose first line is vm.exit_handler.exit_code = 1; (:1821), and global_exit exits with self.exit_handler.exit_code (VirtualMachine.rs:2472) = 1. So the hasExitCode branch at :2418-2420 is dead under the bun run entry point, and the assigned exit code regresses from N to 1 for this case.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed. process.exitCode = 2 with an 'exit' listener that rejects exits 1 under bun run (Node v26.3.0: 2, main: 2 and silent).

The store that replaces the code is the one in print_unhandled_version_note, after on_exit. #45575 moves that store before on_exit. I built a local change that kept the store and passed a flag for an error that is reported during on_exit. A self-review showed two problems with it. It conflicts with every line that #45575 changes there. And the rule behind it is only right when the exit code of a rejection is stored before 'exit'.

So this PR does not change run_command.rs. It is a draft now. It will be rebased on main after #45575 merges, and this row goes into the test table then. The description has the row and the plan.

Comment on lines +2413 to +2420

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Users who assign process.exitCode and whose 'exit' listener leaves a job that throws now exit 1 instead of the assigned code (base: assigned code, silent; Node: assigned code, error printed). The "assigned process.exitCode, or 1" rule at src/jsc/VirtualMachine.rs:2418 only covers the rejection pass. A throwing microtask in drain_microtasks (2408), or a rejection under --unhandled-rejections=throw or strict, goes through uncaught_exception, which calls process_exit(global_object, 1) at 2219 and overwrites exit_code with 1 at src/runtime/node/node_process.rs:71. Fix: once 'exit' is being emitted, every fatal error reported after the listeners must keep the assigned exitCode, e.g. have the fatal path at 2219 pass exit_handler.exit_code when Bun__Process__hasExitCode is true.

Why this was flagged

Trigger: bun -e 'process.exitCode = 2; process.on("exit", () => { queueMicrotask(() => { throw new Error("x") }) })', entered through Run::start -> on_exit -> run_exit_listeners_jobs. At src/jsc/VirtualMachine.rs:2408 drain_microtasks runs the job; its throw reaches ZigGlobalObject::drainMicrotasks's endedByException -> reportUncaughtExceptionAtEventLoop (src/jsc/bindings/ZigGlobalObject.cpp:1236) -> uncaught_exception. No 'uncaughtException' listener, exit_on_uncaught_exception is already true (set by Process__dispatchOnBeforeExit at src/jsc/bindings/BunProcess.cpp:855), so src/jsc/VirtualMachine.rs:2219 calls process_exit(global_object, 1); Bun__Process__exit at src/runtime/node/node_process.rs:71 stores exit_code = 1 and never returns, so the Bun__Process__hasExitCode check at 2418 is never reached. Under --unhandled-rejections=throw or strict the rejection pass takes the same uncaught_exception route (4615, 4629) with the same result. Result: error printed, exit code 1. On the base branch no job runs and the process exits with the assigned 2.

Verification: src/jsc/VirtualMachine.rs:2408 drain_microtasks() runs the job; its throw reaches uncaught_exception. With no 'uncaughtException' listener and exit_on_uncaught_exception already true, :2219 calls process_exit(global_object, 1), which stores exit_code = 1 (src/runtime/node/node_process.rs:71) and never returns, so the Bun__Process__hasExitCode rule at :2419-2421 is never reached. On base the job never ran, so the process exited with the assigned code.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed. The exit code is 1 where Node exits 2, for a job that throws and for a rejection under --unhandled-rejections=throw or strict.

I built the fix at the fatal arm of uncaught_exception: pass the assigned code once 'exit' is being emitted. These rows then matched Node, and another program regressed. process.exitCode = 0, an earlier unhandled rejection and an 'exit' listener that throws exited 0 (Node and main: 1). The cause is that bun run stores the exit code of a reported rejection only after on_exit. uncaught_exception also has two more stores of the literal 1 that the change did not cover, so the code depended on how the exit was reached.

#42032 carries this rule for all three stores, and it needs #45575 first. The rule is not part of this PR anymore. Until it lands, a job that throws exits 1, as a listener that throws does on main. The description lists the row.

}
}

/// Whether `on_exit()` runs `RareData::cleanup_hooks`, i.e. `NapiEnv::cleanup` for
/// each addon. Node does this when it frees an environment: a worker's on any exit,
/// the main thread's only once its loop ran dry (`process.exit()` and a fatal error
Expand Down
13 changes: 13 additions & 0 deletions src/jsc/bindings/BunProcess.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -874,6 +874,19 @@ extern "C" void Process__dispatchOnExit(Zig::GlobalObject* globalObject, uint8_t
dispatchExitInternal(globalObject, process, exitCode);
}

// `process.exitCode` is not undefined.
extern "C" bool Bun__Process__hasExitCode(Zig::GlobalObject* globalObject)
{
return globalObject->hasProcessObject() && globalObject->processObject()->m_isExitCodeObservable;
Comment thread
robobun marked this conversation as resolved.
Resolved
}

// A tick that process.nextTick() queued before 'exit' does not run after it.
extern "C" void Bun__Process__discardNextTicks(Zig::GlobalObject* globalObject)
{
if (auto* nextTickQueue = globalObject->m_nextTickQueue.get())
nextTickQueue->discard(globalObject->vm());
}

JSC_DEFINE_HOST_FUNCTION(Process_functionUptime, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame))
{
double now = static_cast<double>(Bun__readOriginTimer(bunVM(lexicalGlobalObject)));
Expand Down
51 changes: 51 additions & 0 deletions test/js/bun/repl/repl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,19 @@ describe.concurrent("Bun REPL", () => {
expect(exitCode).toBe(0);
});

// .exit is a requested exit, as process.exit() is: nothing runs after the 'exit' listeners.
test(".exit runs process.on('exit') listeners and not their promise jobs", async () => {
const { stdout, stderr, exitCode } = await runRepl([
"process.on('exit', async () => { console.log('exit listener'); await null; console.log('its job'); }), 0",
".exit",
]);
expect({ afterLastPrompt: stripAnsi(stdout).split("> \n").at(-1), stderr, exitCode }).toEqual({
afterLastPrompt: "exit listener\n",
stderr: "",
exitCode: 0,
});
});

test(".help shows help message", async () => {
const { outputs, stderr, exitCode } = await runRepl([".help", ".exit"]);
expect(outputs).toHaveLength(1);
Expand Down Expand Up @@ -961,6 +974,44 @@ describe.concurrent("Bun REPL", () => {
expect(exitCode).toBe(0);
});

// The loop ran dry, as at the end of a script: the promise jobs of the listeners run.
test("-e runs the promise jobs of process.on('exit') listeners", async () => {
const { stdout, stderr, exitCode } = await runReplWith([
"-e",
"process.on('exit', async () => { console.log('exit listener'); await null; console.log('its job'); })",
]);
expect({ stdout, stderr, exitCode }).toEqual({ stdout: "exit listener\nits job\n", stderr: "", exitCode: 0 });
});

// Node reports a rejection that an 'exit' listener leaves and exits with the assigned process.exitCode, or with 1.
test.each([
["", 1],
["process.exitCode = 2;", 2],
["process.exitCode = 0;", 0],
])("-e %j then an 'exit' listener that rejects: exit code %d", async (assignment, code) => {
const { stdout, stderr, exitCode } = await runReplWith([
"-e",
`${assignment} process.on('exit', async () => { throw new Error('cleanup failed'); })`,
]);
expect({ stdout, reported: stderr.includes("error: cleanup failed"), exitCode }).toEqual({
stdout: "",
reported: true,
exitCode: code,
});
});

// The run ended with an unhandled error, not with a dry loop: nothing follows the 'exit' listeners.
test("-e runs no promise job of an 'exit' listener after a rejection that 'beforeExit' work made", async () => {
const { stdout } = await runReplWith([
"-e",
`process.once('beforeExit', () => {
setTimeout(() => { Promise.reject(new Error('cleanup failed')); setTimeout(() => {}, 20); }, 1);
});
process.on('exit', async () => { console.log('exit listener'); await null; console.log('its job'); })`,
]);
expect(stdout).toBe("exit listener\n");
});

test("-e drains event loop (timers fire before exit)", async () => {
const { stdout, stderr, exitCode } = await runReplWith(["-e", "setTimeout(() => console.log('from timer'), 50)"]);
expect(stdout).toBe("from timer\n");
Expand Down
Loading
Loading