Skip to content

bun -p: return module completion value, not first yielded await#30208

Merged
Jarred-Sumner merged 2 commits into
mainfrom
farm/284e7b4e/fix-print-tla
May 4, 2026
Merged

bun -p: return module completion value, not first yielded await#30208
Jarred-Sumner merged 2 commits into
mainfrom
farm/284e7b4e/fix-print-tla

Conversation

@robobun

@robobun robobun commented May 3, 2026

Copy link
Copy Markdown
Collaborator

Repro

$ bun -p '(await 1) + 1'
1
$ bun -p 'await Promise.resolve("hello") + " world"'
hello

Expected: 2 and hello world.

Cause

--print uses ESM module evaluation and captures the last expression
value via EvalGlobalObject::moduleLoaderEvaluate in
src/bun.js/bindings/ZigGlobalObject.cpp. For a module with top-level
await, JSC generator-ifies the body; the first call into
moduleLoader->evaluate() yields the awaited value (1), not the
module's final completion value (2). That yielded value was stored as
the eval result.

The async resume path (asyncModuleExecutionResume in
vendor/WebKit/.../JSMicrotask.cpp) calls module->evaluate()
directly and bypasses the moduleLoaderEvaluate hook, so the hook
could never observe the final value and correct itself.

Fix

After the initial evaluateNonVirtual call, inspect the module
record's generator state. If it yielded (state is a number other than
Executing), the module still has work left and result is the
awaited value. Store the module's asyncCapability() promise instead
— its eventual resolution is the module's actual completion value.

The bun -p loop in src/bun.js.zig already unwraps promises via
asAnyPromise + Bun__onResolveEntryPointResult, so no Zig-side
changes are needed. For non-TLA modules, behavior is unchanged (state
is Executing, result stored as before).

Verification

  • USE_SYSTEM_BUN=1 bun test test/cli/run/run-eval.test.ts -t 'bun -p' → 3 fail, 1 pass
  • bun bd test test/cli/run/run-eval.test.ts -t 'bun -p' → 4 pass
  • Full test/cli/run/run-eval.test.ts (33 tests) and TLA regression tests still pass.

Fixes #30207

For a module with top-level `await`, JSC compiles the body as a
generator and the first call into moduleLoader->evaluate() yields the
awaited value — not the module's final completion value.
EvalGlobalObject::moduleLoaderEvaluate captured that yielded value as
the --print result, and the async resume path in WebKit bypasses this
hook, so it was never corrected.

  bun -p '(await 1) + 1'                       # was 1, now 2
  bun -p 'await Promise.resolve("hi") + " y"' # was hi, now hi y

Fix: when the module record's generator state indicates it yielded
instead of completing, store its async capability promise instead of
the yielded value. The capability resolves to the module's final
completion value, and the --print loop already unwraps promises via
asAnyPromise + Bun__onResolveEntryPointResult.

Fixes #30207
@robobun

robobun commented May 3, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:26 PM PT - May 3rd, 2026

❌ Your commit 1e920a2f has 2 failures in Build #50684 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30208

That installs a local version of the PR into your bun-30208 executable, so you can run:

bun-30208 --bun

@coderabbitai

coderabbitai Bot commented May 3, 2026

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@robobun has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 6 minutes and 45 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d87a34b6-f412-4f9d-9cd4-a0d3d04b33ad

📥 Commits

Reviewing files that changed from the base of the PR and between 1227cdd and 1e920a2.

📒 Files selected for processing (1)
  • test/cli/run/run-eval.test.ts

Walkthrough

When a module evaluated via bun --print contains a top-level await, the eval entry-point result handler now stores the module's asyncCapability (the final async result) instead of the initial yield return value, ensuring full expression results are printed correctly.

Changes

Top-Level Await in Eval Expressions

Layer / File(s) Summary
Core Implementation
src/bun.js/bindings/ZigGlobalObject.cpp
In EvalGlobalObject::moduleLoaderEvaluate, when the eval module is detected as the entry point, the code checks whether the module yielded during execution. If yielded, it stores the module's asyncCapability() as the result; otherwise, it stores the original result.
Tests
test/cli/run/run-eval.test.ts
Added parameterized test suite validating bun -p expression evaluation with awaited arithmetic ((await 1) + 1), awaited promise concatenation, nested awaits, and non-await cases (1 + 1), asserting empty stderr, matching stdout, and exit code 0.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main fix: returning the module's final completion value instead of the first yielded await value in bun -p.
Description check ✅ Passed The description includes a clear repro, thorough explanation of the cause, detailed fix description, and verification steps, covering all template sections.
Linked Issues check ✅ Passed The PR fully addresses issue #30207 by implementing the fix to return module completion values instead of first yielded await values.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the bun -p behavior with top-level await; no unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Review rate limit: 0/5 reviews remaining, refill in 6 minutes and 45 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/cli/run/run-eval.test.ts`:
- Around line 113-127: The test uses test.each([...])("bun -p $expr →
$expected", ...) but repo convention requires table-driven cases be wrapped with
describe.each([...]) and then individual assertions run inside an it/test block;
replace the top-level test.each usage with describe.each([...]) to create a
describe block for each row, and inside that describe add an it or test that
calls Bun.spawnSync with bunExe() and bunEnv and asserts stderr, stdout, and
exitCode as before so the same matrix and expectations are preserved.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: ae2f92fe-a122-4f9e-ab55-031b6b990596

📥 Commits

Reviewing files that changed from the base of the PR and between 9bf6ea3 and 1227cdd.

📒 Files selected for processing (2)
  • src/bun.js/bindings/ZigGlobalObject.cpp
  • test/cli/run/run-eval.test.ts

Comment thread test/cli/run/run-eval.test.ts Outdated
Coderabbit nit — switch to describe.each(...)+test pattern to match the
wider repo convention.
@robobun

robobun commented May 4, 2026

Copy link
Copy Markdown
Collaborator Author

Windows 2019 x64-baseline shard [83/506] failed on test-http-should-emit-close-when-connection-is-aborted.ts with a 4× timeout — pre-existing Windows flake. Same test times out on the same lane in unrelated builds #50610, #50620, #50650, #50660, #50670, #50676, #50678, #50682. My change only affects EvalGlobalObject::moduleLoaderEvaluate, which only runs when evalMode=true (i.e. bun -p/-e) — bun test uses the regular GlobalObject path. Not re-running, since it will hit the same flake.

@claude claude Bot left a comment

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.

I didn't find any bugs, but this leans on JSC internals (generator State values and asyncCapability() semantics in the async-module resume path) that I'd want a maintainer familiar with the WebKit module loader to sanity-check before merging.

Extended reasoning...

Overview

This PR changes EvalGlobalObject::moduleLoaderEvaluate in src/bun.js/bindings/ZigGlobalObject.cpp so that when the bun --print entry-point module contains top-level await, the captured eval result is the module's asyncCapability() promise (whose resolution is the module's final completion value) rather than the first yielded awaited value returned from evaluateNonVirtual. It also adds a 4-case describe.each block to test/cli/run/run-eval.test.ts covering TLA and non-TLA expressions.

Security risks

None apparent. The change is read-only inspection of an existing AbstractModuleRecord plus a different JSValue passed to Bun__VM__setEntryPointEvalResultESM. It is gated behind Bun__VM__specifierIsEvalEntryPoint, so it only affects the bun -e/bun -p entry module, not general module loading. No new untrusted-input parsing, auth, or permission surfaces.

Level of scrutiny

Medium-high. Although the diff is small (~20 LOC) and the non-TLA path is provably unchanged (falls through to the original result), the correctness of the TLA path depends on JSC implementation details: that a yielded-but-not-finished async module leaves its State internal field as a number other than JSGenerator::State::Executing, that asyncCapability() is populated at this point, and that its resolution carries the module body's completion value (vs. undefined). These are vendor-WebKit invariants rather than public API, so a reviewer who knows the JSC async-module machinery should confirm them.

Other factors

  • The fix is defensive: dynamicDowncast and the null asyncCapability() check both fall back to the previous behavior, so the worst realistic failure mode is the original (wrong) value rather than a crash.
  • Tests verify the user-visible behavior end-to-end via bun -p subprocesses and include a non-TLA control case.
  • The only review feedback (CodeRabbit's describe.each suggestion) was addressed in 1e920a2.
  • robobun's CI comment shows widespread build-zig failures on the earlier commit 1227cdd; these look infrastructure-related (this PR touches no Zig), but CI should be green before merge.

@robobun

robobun commented May 4, 2026

Copy link
Copy Markdown
Collaborator Author

Build #50684 final: 4 failures, all pre-existing flakes unrelated to this PR:

  • 3× Windows :windows: 2019 x64, :windows: 2019 x64-baseline, :windows: 11 aarch64: test/js/bun/test/parallel/test-http-should-emit-close-when-connection-is-aborted.ts times out 4×. Same test + same lanes on #50676, #50678, #50682, #50660, #50650, #50670 — unrelated branches.
  • :darwin: 14 aarch64: test/js/bun/s3/s3-storage-class.test.tsS3Error: an unexpected error has occurred (external S3 flake). Also seen on #50650.

This PR only changes EvalGlobalObject::moduleLoaderEvaluate (active when evalMode=true, i.e. bun -p/-e). The failing tests all run via bun run <file>, which uses the regular GlobalObject. No code path connects my change to any of these failures.

@Jarred-Sumner Jarred-Sumner merged commit 4f13b9c into main May 4, 2026
74 of 77 checks passed
@Jarred-Sumner Jarred-Sumner deleted the farm/284e7b4e/fix-print-tla branch May 4, 2026 01:36
xhjkl pushed a commit to xhjkl/bun that referenced this pull request May 14, 2026
…-sh#30208)

## Repro

```console
$ bun -p '(await 1) + 1'
1
$ bun -p 'await Promise.resolve("hello") + " world"'
hello
```

Expected: `2` and `hello world`.

## Cause

`--print` uses ESM module evaluation and captures the last expression
value via `EvalGlobalObject::moduleLoaderEvaluate` in
`src/bun.js/bindings/ZigGlobalObject.cpp`. For a module with top-level
`await`, JSC generator-ifies the body; the first call into
`moduleLoader->evaluate()` yields the awaited value (`1`), not the
module's final completion value (`2`). That yielded value was stored as
the eval result.

The async resume path (`asyncModuleExecutionResume` in
`vendor/WebKit/.../JSMicrotask.cpp`) calls `module->evaluate()`
directly and bypasses the `moduleLoaderEvaluate` hook, so the hook
could never observe the final value and correct itself.

## Fix

After the initial `evaluateNonVirtual` call, inspect the module
record's generator state. If it yielded (state is a number other than
`Executing`), the module still has work left and `result` is the
awaited value. Store the module's `asyncCapability()` promise instead
— its eventual resolution is the module's actual completion value.

The `bun -p` loop in `src/bun.js.zig` already unwraps promises via
`asAnyPromise` + `Bun__onResolveEntryPointResult`, so no Zig-side
changes are needed. For non-TLA modules, behavior is unchanged (state
is `Executing`, `result` stored as before).

## Verification

- `USE_SYSTEM_BUN=1 bun test test/cli/run/run-eval.test.ts -t 'bun -p'`
→ 3 fail, 1 pass
- `bun bd test test/cli/run/run-eval.test.ts -t 'bun -p'` → 4 pass
- Full `test/cli/run/run-eval.test.ts` (33 tests) and TLA regression
tests still pass.

Fixes oven-sh#30207

---------

Co-authored-by: robobun <robobun@bun.sh>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bun --print with an expression containing await prints the result of the await instead of the entire expression

2 participants