From 82b9967255f6674407b47400077306dfdc54d263 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:46:02 +0000 Subject: [PATCH] --print: exit through Run::start when the result promise is still pending When the --print result was still pending after the event loop was done, Run::start attached two native reactions to it and ticked once more; the reaction that ran printed the settled value and called Global::exit from inside the promise job, skipping on_before_exit, handle_rejected_promises, on_exit, the unhandled-error exit code and global_exit. Attaching them also marked the result promise handled, so its own late rejection was never reported. Re-read the promise's status after the extra turn instead and print from the one existing site, so the run ends through the regular exit sequence. The extra turn is now only taken when the loop drained on its own. After an unhandled error stopped it, the turn ran whatever of the script's timers came due before the next internal wakeup, so what --print printed (and, via the reactions, the exit code) depended on the timer's length; as in on_before_exit, nothing of the script runs after such an error. Removes the reactions, their PromiseFunctions slots and the mordant baseline entry for them. --- mordant-baseline.toml | 1 - src/jsc/bindings/ZigGlobalObject.cpp | 8 +-- src/jsc/bindings/ZigGlobalObject.h | 4 +- src/jsc/bindings/headers.h | 4 -- src/runtime/cli/run_command.rs | 51 ++++++++------- src/runtime/hw_exports.rs | 48 --------------- test/cli/run/run-eval.test.ts | 92 ++++++++++++++++++++++++++++ 7 files changed, 120 insertions(+), 88 deletions(-) diff --git a/mordant-baseline.toml b/mordant-baseline.toml index 2e6f3cb583f8..54b11b7d93b9 100644 --- a/mordant-baseline.toml +++ b/mordant-baseline.toml @@ -42,7 +42,6 @@ "error_collapsed_to_bool:src/runtime/ffi/ffi_body.rs" = 1 "field_valid_only_when:src/runtime/shell/builtin/rm.rs" = 1 "reimplemented_helper:src/runtime/api/bun/Terminal.rs" = 1 -"reimplemented_helper:src/runtime/hw_exports.rs" = 1 "same_match_twice:src/runtime/api/bun/h2_frame_parser.rs" = 1 "same_match_twice:src/runtime/cli/pack_command.rs" = 1 "same_match_twice:src/runtime/cli/update_interactive_command.rs" = 2 diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index ca0613b6d856..e8a1c103b55a 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -3993,8 +3993,8 @@ JSC::JSValue EvalGlobalObject::moduleLoaderEvaluate(JSGlobalObject* lexicalGloba // // Instead, when the module yielded, capture the async capability's // promise. Its resolution value is the module's final completion - // value; the --print loop in run_command.rs already unwraps promises - // via asAnyPromise + Bun__onResolveEntryPointResult. + // value; the --print path in run_command.rs unwraps a promise result + // once the event loop has drained. JSC::JSValue valueToStore = result; if (auto* moduleRecord = dynamicDowncast(moduleRecordValue)) { JSC::JSValue state = moduleRecord->internalField(JSC::AbstractModuleRecord::Field::State).get(); @@ -4127,10 +4127,6 @@ GlobalObject::PromiseFunctions GlobalObject::promiseHandlerID(Zig::FFIFunction h return GlobalObject::PromiseFunctions::Bun__HTMLRewriter__onHandlerResolve; } else if (handler == Bun__HTMLRewriter__onHandlerReject) { return GlobalObject::PromiseFunctions::Bun__HTMLRewriter__onHandlerReject; - } else if (handler == Bun__onResolveEntryPointResult) { - return GlobalObject::PromiseFunctions::Bun__onResolveEntryPointResult; - } else if (handler == Bun__onRejectEntryPointResult) { - return GlobalObject::PromiseFunctions::Bun__onRejectEntryPointResult; } else if (handler == Bun__NodeHTTPRequest__onResolve) { return GlobalObject::PromiseFunctions::Bun__NodeHTTPRequest__onResolve; } else if (handler == Bun__NodeHTTPRequest__onReject) { diff --git a/src/jsc/bindings/ZigGlobalObject.h b/src/jsc/bindings/ZigGlobalObject.h index 35a8a4f69ec6..441465aa4a77 100644 --- a/src/jsc/bindings/ZigGlobalObject.h +++ b/src/jsc/bindings/ZigGlobalObject.h @@ -413,8 +413,6 @@ class GlobalObject : public Bun::GlobalScope { Bun__TestScope__Describe2__bunTestCatch, Bun__HTMLRewriter__onHandlerResolve, Bun__HTMLRewriter__onHandlerReject, - Bun__onResolveEntryPointResult, - Bun__onRejectEntryPointResult, Bun__NodeHTTPRequest__onResolve, Bun__NodeHTTPRequest__onReject, Bun__FileStreamWrapper__onRejectRequestStream, @@ -438,7 +436,7 @@ class GlobalObject : public Bun::GlobalScope { Bun__HTMLRewriter__onResolveInputStream, Bun__HTMLRewriter__onRejectInputStream, }; - static constexpr size_t promiseFunctionsSize = 48; + static constexpr size_t promiseFunctionsSize = 46; static PromiseFunctions promiseHandlerID(SYSV_ABI EncodedJSValue (*handler)(JSC::JSGlobalObject* arg0, JSC::CallFrame* arg1)); diff --git a/src/jsc/bindings/headers.h b/src/jsc/bindings/headers.h index 8c1f1f6a1ffd..8e5d32aa8d18 100644 --- a/src/jsc/bindings/headers.h +++ b/src/jsc/bindings/headers.h @@ -816,10 +816,6 @@ CPP_DECL bool JSC__CustomGetterSetter__isSetterNull(JSC::CustomGetterSetter *arg #ifdef __cplusplus -BUN_DECLARE_HOST_FUNCTION(Bun__onResolveEntryPointResult); -BUN_DECLARE_HOST_FUNCTION(Bun__onRejectEntryPointResult); - - BUN_DECLARE_HOST_FUNCTION(Bun__FileStreamWrapper__onResolveRequestStream); BUN_DECLARE_HOST_FUNCTION(Bun__FileStreamWrapper__onRejectRequestStream); diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index b0a92bc2ac43..80eb3f4e64af 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -1495,37 +1495,36 @@ impl Run<'_> { } if ctx.runtime_options.eval.eval_and_print { - let to_print: JSValue = 'brk: { - let result = vm - .entry_point_result - .value - .get() - .unwrap_or(JSValue::UNDEFINED); - if let Some(promise) = result.as_any_promise() { - match promise.status() { - PromiseStatus::Pending => { - // C-ABI shims are emitted by - // `generate-host-exports.ts` into - // `crate::generated_host_exports` under their - // link name (`Bun__on…EntryPointResult`). - result.then2( - vm.global(), - JSValue::UNDEFINED, - crate::generated_host_exports::Bun__onResolveEntryPointResult, - crate::generated_host_exports::Bun__onRejectEntryPointResult, - ); + let result = vm + .entry_point_result + .value + .get() + .unwrap_or(JSValue::UNDEFINED); + let to_print: JSValue = match result.as_any_promise() { + Some(promise) => { + // Drained with the result unsettled (only unref'd work + // left): one more turn, then print whatever state it is + // in. Not when an unhandled error stopped the loop: as in + // `on_before_exit`, nothing of the script runs after that + // (the turn would only race its timers against the next + // internal wakeup). Exiting stays with the sequence + // below; a reaction on the promise would bypass it. + if promise.status() == PromiseStatus::Pending + && vm.unhandled_error_counter == 0 + { + vm.tick(); + vm.auto_tick_active(); + while vm.is_event_loop_alive() { vm.tick(); vm.auto_tick_active(); - while vm.is_event_loop_alive() { - vm.tick(); - vm.auto_tick_active(); - } - break 'brk result; } - _ => break 'brk promise.result(vm.jsc_vm()), + } + match promise.status() { + PromiseStatus::Pending => result, + _ => promise.result(vm.jsc_vm()), } } - result + None => result, }; // SAFETY: `vals[..1]` is the single stack `to_print`; null // `ctype` routes to the VM's stdout/stderr default. diff --git a/src/runtime/hw_exports.rs b/src/runtime/hw_exports.rs index c3158beb8a39..94ff03eb921d 100644 --- a/src/runtime/hw_exports.rs +++ b/src/runtime/hw_exports.rs @@ -302,54 +302,6 @@ mod sql_hooks { }; } -// ─── entry-point promise reactions (used by `--print`) ─────────────────────── - -// HOST_EXPORT(Bun__onResolveEntryPointResult) -pub fn on_resolve_entry_point_result( - global: &JSGlobalObject, - callframe: &CallFrame, -) -> bun_jsc::JsResult { - let result = callframe.argument(0); - // SAFETY: `vals[..len]` is the single stack `result`; `ctype` is ignored by - // `message_with_type_and_level` (it always resolves the per-VM console via - // `vm_console(global)`), so null is fine. - unsafe { - bun_jsc::ConsoleObject::message_with_type_and_level( - core::ptr::null_mut(), - bun_jsc::ConsoleObject::MessageType::Log, - bun_jsc::ConsoleObject::MessageLevel::Log, - global, - &raw const result, - 1, - ); - } - // SAFETY: bun_vm() never null for a Bun-owned global. - bun_core::Global::exit(u32::from(global.bun_vm().as_mut().exit_handler.exit_code)); -} - -// HOST_EXPORT(Bun__onRejectEntryPointResult) -pub fn on_reject_entry_point_result( - global: &JSGlobalObject, - callframe: &CallFrame, -) -> bun_jsc::JsResult { - let result = callframe.argument(0); - // SAFETY: `vals[..len]` is the single stack `result`; `ctype` is ignored by - // `message_with_type_and_level` (it always resolves the per-VM console via - // `vm_console(global)`), so null is fine. - unsafe { - bun_jsc::ConsoleObject::message_with_type_and_level( - core::ptr::null_mut(), - bun_jsc::ConsoleObject::MessageType::Log, - bun_jsc::ConsoleObject::MessageLevel::Log, - global, - &raw const result, - 1, - ); - } - // SAFETY: bun_vm() never null for a Bun-owned global. - bun_core::Global::exit(u32::from(global.bun_vm().as_mut().exit_handler.exit_code)); -} - // ─── bindgenv2 dispatch shims (`bindgen_*_dispatch*`) ──────────────────────── // // These satisfy the `extern "C"` refs C++ emits from diff --git a/test/cli/run/run-eval.test.ts b/test/cli/run/run-eval.test.ts index 0f928a7ce6a7..83cdd76b1b81 100644 --- a/test/cli/run/run-eval.test.ts +++ b/test/cli/run/run-eval.test.ts @@ -175,6 +175,98 @@ describe("--print for cjs/esm", () => { }); }); +// `--print` looks at the result once the event loop is done. A result promise +// that is still pending when the loop drained on its own gets one more turn of +// the loop; one left pending because an unhandled error stopped the loop does +// not (nothing of the script runs after such an error, as with `-e`). Either +// way the process then leaves through the regular exit path ('exit' listeners, +// exit code), not from inside a reaction on the promise. +// +// Every script below arms the timer that would settle the result and then +// blocks in Bun.sleepSync, so the timer is overdue by the time the loop is +// looked at: it fires for sure in the extra turn of the unref cases, and its +// callback not having run in the error cases shows that nothing ran after the +// error, whatever the timing. +describe.concurrent("--print with a result promise still pending when the event loop is done", () => { + const exitListener = `process.on("exit", () => console.log("exit listener ran"));`; + + async function print(script: string) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "--print", script], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + test("fulfilled by an unref'd timer: prints the value, then runs 'exit' listeners", async () => { + const { stdout, stderr, exitCode } = await print( + `${exitListener} + new Promise(resolve => { setTimeout(() => resolve("settled late"), 1).unref(); Bun.sleepSync(10); })`, + ); + expect(stderr).toBe(""); + expect(stdout).toBe("settled late\nexit listener ran\n"); + expect(exitCode).toBe(0); + }); + + test("rejected by an unref'd timer: reported as unhandled and exits 1, like a rejection during the loop", async () => { + const { stdout, stderr, exitCode } = await print( + `${exitListener} + new Promise((_, reject) => { setTimeout(() => reject("rejected late"), 1).unref(); Bun.sleepSync(10); })`, + ); + expect(stderr).toContain("error: rejected late"); + // What a rejected result is printed as is not this block's subject. + expect(stdout).toEndWith("exit listener ran\n"); + expect(exitCode).toBe(1); + }); + + test.each(["resolve", "reject"])( + "unhandled rejection stopped the loop: the %s() timer does not run, exits 1 after 'exit' listeners", + async settle => { + const { stdout, stderr, exitCode } = await print( + `${exitListener} + Promise.reject(new Error("early rejection")); + new Promise((resolve, reject) => { + setTimeout(() => { console.log("timer ran"); ${settle}("late"); }, 1); + Bun.sleepSync(10); + })`, + ); + expect(stderr).toContain("early rejection"); + expect(stdout).toBe("Promise { }\nexit listener ran\n"); + expect(exitCode).toBe(1); + }, + ); + + test("uncaught exception from a timer stopped the loop: 'exit' listeners see code 1", async () => { + // The settling timer is armed by the throwing callback itself. Whether the + // turn of the loop that ran the throw still fires it differs by platform + // (libuv runs timers again after its poll), so what gets printed is not + // asserted, only that the process left through the exit listeners. + const { stdout, stderr, exitCode } = await print( + `process.on("exit", code => console.log("exit listener ran with", code)); + new Promise(resolve => { + setTimeout(() => { + setTimeout(() => resolve("settled late"), 1); + Bun.sleepSync(10); + throw new Error("thrown in timer"); + }, 1); + })`, + ); + expect(stderr).toContain("thrown in timer"); + expect(stdout).toEndWith("exit listener ran with 1\n"); + expect(exitCode).toBe(1); + }); + + test("never settles: prints the promise and exits normally", async () => { + const { stdout, stderr, exitCode } = await print(`${exitListener} new Promise(() => {})`); + expect(stderr).toBe(""); + expect(stdout).toBe("Promise { }\nexit listener ran\n"); + expect(exitCode).toBe(0); + }); +}); + function group(run: (code: string) => SyncSubprocess<"pipe", "inherit">) { test("it works", async () => { const { stdout } = run('console.log("hello world")');