Skip to content

bun:test: print a function's own name instead of its AsyncFunction / GeneratorFunction tag - #38959

Open
robobun wants to merge 3 commits into
mainfrom
farm/56c20272/test-formatter-function-name-before-tostringtag
Open

robobun wants to merge 3 commits into
mainfrom
farm/56c20272/test-formatter-function-name-before-tostringtag

Conversation

@robobun

@robobun robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • In bun:test, a function declared with a name prints as its kind instead of its name when it is async or a generator: toMatchSnapshot() stores "load": [Function: AsyncFunction] for async function load() {} ([Function: GeneratorFunction] / [Function: AsyncGeneratorFunction] for the other kinds), and toEqual / toStrictEqual failure diffs show the same text, so two async functions in one object are indistinguishable. A plain function load() {} prints [Function: load], and console.log(load) prints [AsyncFunction: load]; only the test runner's formatter is affected. Found by inspection while working on Print JSX component tags with the component's inferred name or displayName #38939 (the same lookup names JSX tags there); there is no tracker issue for it, and the behaviour dates back to the formatter's first version.
  • The same lookup names JSX tags (an async component prints as <AsyncFunction />, in both bun:test output and Bun.inspect) and, through getClassName, the [class X] printer, describe(Class), expect.any(Class) and the "Expected constructor:" line of toThrow(Class).
  • Cause: JSC__JSValue__getNameProperty (src/jsc/bindings/bindings.cpp, the binding behind JSValue::get_name_property, used by the Tag::Function arm of src/runtime/test_runner/pretty_format.rs and by getClassName for functions) reads Symbol.toStringTag with getIfPropertyExists, which walks the prototype chain, before it looks at the function. AsyncFunction.prototype[Symbol.toStringTag] is "AsyncFunction" (likewise GeneratorFunction / AsyncGeneratorFunction), so for these functions the name is never consulted.

Fix

  • getNameProperty now takes the JSFunction / InternalFunction name first and reads Symbol.toStringTag only when the object has no name of its own. The name sources and the tag lookup are the ones the function already used; only the order changes (plus an exception check after resolving the tag string).
  • Why this order is right: on a function, Symbol.toStringTag is never its name, it is the kind inherited from the prototype (or, for a class with a static [Symbol.toStringTag], a description of the class object); the name is what console.log, node's util.inspect and jest's [Function name] all print first. Non-function objects never reach the name branches, so the Symbol.toStringTag behaviour that handle_first_property relies on for the Foo { prefix of tagged plain objects is unchanged.
  • Scope, stated plainly: only functions with a name of their own change. A function whose name is merely inferred from its binding or property key, which is the most common async shape (const save = async () => {}, { update: async () => {} }), still prints exactly as today, [Function: AsyncFunction], through the tag fallback, just as const f = () => {} still prints [Function]. This formatter has never printed inferred names (Fix missing function names in console.log and Bun.inspect #6612 changed that for console.log only) and existing .snap files with such functions depend on it, so moving this printer to console.log's resolution ([Function: save], or [AsyncFunction: save]) is a snapshot-format change for a separate decision; this PR only fixes the output that was wrong under the formatter's existing rule. The second new test pins the unchanged cases so that a later change to the rule is made on purpose.
  • Also affected, in the same direction: <Page /> instead of <AsyncFunction /> for an async JSX component in both printers (Print JSX component tags with the component's inferred name or displayName #38939 reworks JSX tag naming separately and composes with this), and a class with a static Symbol.toStringTag getter now prints as [class Tagged] / Any<Tagged> / Expected constructor: Tagged instead of the tag, matching what console.log prints for the class. inspect: display a class or function name set with Object.defineProperty #38922 and Name anonymous export default functions and classes "default" in stack traces and inspect #38517 add name sources inside the same branch of this function; whichever of those and this lands second needs a small textual rebase, and the reorder keeps their additions ahead of the tag lookup.
  • Verified:
    • test/js/bun/test/snapshot-tests/snapshots/moremore.test.ts, "async and generator functions print their own name": declaration, generator, async generator, named function expression, bound async function, object-literal async / generator methods, a class with a static Symbol.toStringTag, the toEqual diff text, and a JSX element whose type is an async function. Fails on bun 1.4.0 (every one of those prints as its kind), passes with this change.
    • Same file, "functions without a name of their own print the way existing snapshots have them": arrow, async arrow, anonymous generator and async generator expressions. Passes before and after; it pins the scope described above.
    • With the debug build: the rest of snapshot-tests/, describe.test.ts, expect.test.js, expect-extend.test.js, jest-extended.test.js, spyMatchers.test.ts, test-test.test.ts, util/inspect.test.js, console/console-log.test.ts pass. printing/diffexample.test.ts differs only in an unstripped [4.22s] duration and pretty-format-overflow.test.ts overflows the stack, both pre-existing under debug builds and both already tracked by open PRs; the ~700 lines of diff output in the former are byte-identical.
    • A Symbol.toStringTag getter that throws while a diff is being formatted trips a JSC exception-scope assertion in debug builds before and after this change alike (the diff formatter discards the formatter's error at diff_format.rs:47); that is a separate pre-existing bug and has been reported on its own.

Background

  • Symbol.toStringTag: the property Object.prototype.toString reads to produce [object Foo]. JSC defines it on AsyncFunction.prototype, GeneratorFunction.prototype and AsyncGeneratorFunction.prototype, so every function of those kinds inherits it; ordinary functions inherit nothing from Function.prototype. Bun's formatters also use it to prefix plain objects that carry one (Foo {}).
  • JSFunction::name(): the name a function was declared with (function load() {}, async get() {} in a literal, a bound function's target name). A name that was only inferred from a binding (const f = () => {}) is stored separately (ecmaName) and is what f.name and getCalculatedDisplayName (used by console.log) report; getNameProperty never read it, and still does not. InternalFunction is the base class of native constructors such as Promise or Error, which carry their name directly.
  • getClassName (bindings.cpp) delegates to getNameProperty whenever the cell's class is Function, i.e. for every function and class value, which is how the [class X] printer and the matcher messages above share this code path.
Before / after (bun:test snapshot output)
async function load() {}            "load":   [Function: AsyncFunction]           -> [Function: load]
const save = async () => {};        "save":   [Function: AsyncFunction]           -> [Function: AsyncFunction]   (unchanged, inferred name only)
function* gen() {}                  "gen":    [Function: GeneratorFunction]       -> [Function: gen]
async function* agen() {}           "agen":   [Function: AsyncGeneratorFunction]  -> [Function: agen]
{ async m1() {} }                   "m1":     [Function: AsyncFunction]           -> [Function: m1]
load.bind(null)                     "bound":  [Function: AsyncFunction]           -> [Function: load]
const arrow = () => {};             "arrow":  [Function]                          -> [Function]                  (unchanged)
fs.promises.readFile                          [Function: AsyncFunction]           -> [Function: AsyncFunction]   (unchanged, builtin with an empty name)
class T { static get [Symbol.toStringTag]() { return "X" } }
                                    "T":      [class X]                           -> [class T]
JSX element with type: load                   <AsyncFunction />                   -> <load />

JSC__JSValue__getNameProperty read Symbol.toStringTag (through the
prototype chain) before looking at the function itself, so every async,
generator and async generator function came back as "AsyncFunction",
"GeneratorFunction" or "AsyncGeneratorFunction" and the test runner's
formatter printed all of them as [Function: AsyncFunction] in snapshots,
toEqual diffs and JSX tags. Look up the JSFunction / InternalFunction
name first and only fall back to Symbol.toStringTag when the function
has no name of its own, so anonymous functions print exactly as before.
@coderabbitai

coderabbitai Bot commented Aug 15, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 15 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 752578ba-60b2-49f3-954d-82e01b83629b

📥 Commits

Reviewing files that changed from the base of the PR and between 63ffef8 and fbdb43f.

📒 Files selected for processing (2)
  • src/jsc/bindings/bindings.cpp
  • test/js/bun/test/snapshot-tests/snapshots/moremore.test.ts

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

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 10:37 AM PT - Aug 15th, 2026

✅ @robobun, your commit fbdb43ff5822cb557913b8d6f189262b1166d843 passed in Build #98038! 🎉


🧪   To try this PR locally:

bunx bun-pr 38959

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

bun-38959 --bun

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: fixed, ready for review.

  • Reproduced on bun 1.4.0 with USE_SYSTEM_BUN=1 bun test test/js/bun/test/snapshot-tests/snapshots/moremore.test.ts: the new "async and generator functions print their own name" test fails with [Function: AsyncFunction], [Function: GeneratorFunction], [Function: AsyncGeneratorFunction] and [class NotTheClassName] where the names should be.
  • Passes with the debug build after the getNameProperty reorder in src/jsc/bindings/bindings.cpp; the related bun:test, snapshot, inspect and console suites listed in the description pass as well.
  • CI: every lane that has run is green on both builds of this diff (177 jobs each; the remaining job-level reds were retries that passed). The only thing keeping the builds from finishing green is the darwin 14 aarch64 - test-bun pair of shards, which expired waiting for an agent on the first build and is queued again on the second; the change is platform-independent JSC name lookup, so nothing in it is macOS-specific. Review feedback so far (comment length) is addressed in fbdb43f.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Print JSX component tags with the component's inferred name or displayName #38939 - Diagnoses the same root cause (getNameProperty reading Symbol.toStringTag before the function's own name, so the inherited AsyncFunction tag wins) and independently fixes the same <AsyncFunction /> JSX symptom, via the JSX printers rather than the binding.

🤖 Generated with Claude Code

@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.

LGTM — reorders existing name-lookup branches so a function's own name wins over the inherited Symbol.toStringTag.

What was reviewed:

  • Confirmed the JSFunction / InternalFunction / toStringTag branches are the same code as before, only reordered, with an added RETURN_IF_EXCEPTION after toWTFString.
  • Checked all four get_name_property callers (pretty_format.rs Tag::Function / JSX / handle_first_property, ConsoleObject.rs JSX) — the non-function handle_first_property path still hits toStringTag first since both downcasts fail.
  • Grepped test/ for [Function: AsyncFunction] / GeneratorFunction / [class ...] in snapshots — no existing snapshot output changes beyond the new tests.
Extended reasoning...

Overview

The PR reorders three existing branches inside JSC__JSValue__getNameProperty in src/jsc/bindings/bindings.cpp (~30 lines) so that JSFunction::name() / jsExecutable()->name() and InternalFunction::name() are consulted before the Symbol.toStringTag lookup, and adds a missing RETURN_IF_EXCEPTION after toWTFString. Two new tests in moremore.test.ts cover the fixed cases (named async/generator/bound/method functions, a class with a static Symbol.toStringTag, a JSX element) and pin the deliberately-unchanged cases (arrows and anonymous expressions with only an inferred name).

Security risks

None. This is display-only logic in the test-runner pretty formatter and getClassName; no user input parsing, no allocation sizing, no privilege boundaries.

Level of scrutiny

Low-to-medium. The change is a reorder of code that already existed in the function — no new name sources, no new lookup calls beyond what was already there. The one semantic tweak beyond ordering is that empty-named JSFunction/InternalFunction values now fall through to toStringTag instead of returning an empty string, which is what preserves the [Function: AsyncFunction] output for nameless async arrows and host builtins (verified by the second new test and the fs.promises.readFile example in the PR body).

Other factors

  • All four Rust callers of get_name_property were checked: the handle_first_property path in pretty_format.rs is gated on !is_function(), so plain-object toStringTag prefixing is unaffected by the reorder; the JSX and Tag::Function paths handle len == 0 explicitly.
  • A grep across test/ for [Function: AsyncFunction|GeneratorFunction|AsyncGeneratorFunction] and .snap files for [class found no existing snapshots that would flip.
  • The PR description enumerates every affected surface (describe(Class), expect.any, toThrow(Class), JSX tags) via getClassName, and the author ran the full snapshot/expect/inspect suites under a debug build.
  • No prior reviewer comments; bug-hunting system found nothing.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Not a duplicate of #38939. That PR changes the two JSX printers (print_jsx in ConsoleObject.rs, the Tag::JSX arm in pretty_format.rs) to stop using get_name_property for the tag name, and its description states that the [Function: AsyncFunction] output of the test runner's function printer is left byte-identical. This PR fixes that remaining output by reordering the lookup inside the binding itself, which is what the Tag::Function arm, getClassName ([class X], describe(Class), expect.any(Class), toThrow(Class) messages) go through. The <AsyncFunction /> JSX tag is the one symptom the two PRs share; once #38939 lands the JSX printers no longer call this binding at all, so the two changes compose in either merge order and neither makes the other unnecessary.

Comment thread src/jsc/bindings/bindings.cpp Outdated

@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.

LGTM — a straightforward reorder of two existing lookups in getNameProperty, with an added exception check.

What was reviewed:

  • Traced the behavioral delta for each object shape: named JSFunction, host/builtin with empty name, InternalFunction, and non-function objects with Symbol.toStringTag — the only changed outputs are the ones the tests pin.
  • Confirmed the else if is safe (JSFunction and InternalFunction are disjoint hierarchies) and that the new RETURN_IF_EXCEPTION after toWTFString closes a pre-existing gap.
  • Checked callers (getClassName, Tag::Function / Tag::JSX in pretty_format.rs, ConsoleObject.rs) — non-function callers still reach the Symbol.toStringTag path unchanged.
Extended reasoning...

Overview

This PR reorders JSC__JSValue__getNameProperty in src/jsc/bindings/bindings.cpp so that a function's own name (JSFunction::name() / InternalFunction::name()) is consulted before Symbol.toStringTag. Previously the tag was read first via prototype-chain lookup, so async/generator/async-generator functions — which inherit Symbol.toStringTag from their prototype — always printed as [Function: AsyncFunction] etc. in bun:test snapshots and diffs, hiding their actual name. The change is a pure reorder of existing lookup blocks plus one added RETURN_IF_EXCEPTION after toWTFString. Two new tests in moremore.test.ts cover the fix (named async/generator functions, bound functions, object methods, a class with a static Symbol.toStringTag, toEqual diff text, JSX tag) and pin the backward-compat behavior for functions without an own name.

Security risks

None. This is display-formatting logic for the test runner and inspectors. No parsing of untrusted input, no allocation-size arithmetic, no auth/crypto/permission code.

Level of scrutiny

Medium-low. The function is ~35 lines and the change is a reorder, not new logic. I traced each object shape through both old and new code: named JSFunctions now return their name (the fix); host/builtin functions with empty names fall through to the tag exactly as before; InternalFunctions with empty names now fall through to the tag instead of returning empty (a harmless improvement); non-function objects skip both function branches and hit the tag lookup unchanged, so the Foo { prefix path in handle_first_property is unaffected. The else if between JSFunction and InternalFunction is safe because they are disjoint JSC class hierarchies. No memory-safety concerns: no new allocations, same Zig::toZigString return pattern as before.

Other factors

The PR author verified the new test fails on bun 1.4.0 with USE_SYSTEM_BUN=1 and passes on the debug build, and ran the wider snapshot/expect/inspect/console suites. The comment-cop bot's feedback about the long code comment was addressed in fbdb43f (now one line). The interaction with #38939 (JSX printers) is documented and composes cleanly. No CODEOWNERS restrictions apply.

This branch has not been deployed

No deployments
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.

1 participant