Skip to content

[JSC] The function that runs class field initializers is a frame in a stack trace - #1003

Draft
robobun wants to merge 1 commit into
mainfrom
robobun/2a21e0d6/class-field-initializer-frames
Draft

robobun wants to merge 1 commit into
mainfrom
robobun/2a21e0d6/class-field-initializer-frames

Conversation

@robobun

@robobun robobun commented Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator

Do not merge alone. Merge it together with the Bun PR that pins it (see Notes).

Problem

  • An error made in a class field initializer is reported at the wrong place: at new W (unknown:1:11) and (near '...(function () { })...') for class W { port = cfg.port }. A static field is reported at the class keyword.
  • emitNewClassFieldInitializerFunction (bytecompiler/BytecodeGenerator.cpp:3828) makes the function that runs the initializers ImplementationVisibility::Private. Every stack walk skips it.

Fix

  • Under USE(BUN_JSC_ADDITIONS) that function is Public, named <instance_members_initializer> or <static_initializer> as in V8.
  • The function of a static block takes the static name. With static blocks only, the function that calls them stays hidden, so a block lists one frame.
  • A field records expression info before its initializer and before a named define, so an operation with no position of its own is reported at its field.
  • Verified: JSTests/stress/bun-class-field-initializer-stack-frames.js (fails before), JSTests/controlFlowProfiler, JSTests/stress.

Background

  • A class has one synthesized function for its instance fields and one for its static elements. A static block is a function of its own, called from the static one.
  • Upstream hid these functions on purpose (252554@main). V8 shows them.
  • Considered an arrow around each decorated initializer in Bun's lowering: a closure and a call per new, and plain fields stay wrong.

Downsides

  • A trace through an initializer has one more frame, and Error.stackTraceLimit counts it. Through a static block of a class with static fields it has two <static_initializer> frames. V8 lists one.
  • Per new: 0. A class with fields costs 500 to 700 more instructions when first evaluated. new Error() in an initializer costs 87 more, 4,067 more with .stack.
  • An unread Error from a static field is formatted by the first collection after the class is defined, without Error.prepareStackTrace. Static blocks already behave so ([JSC] An error keeps a record per frame of a stack trace that a collection formats #873).
Notes

Order of merges. Bun's ahead-of-time compiler hides the same frame in compiled code (src/aot/lower/class/fields.rs), and compiled programs are compared with plain Bun, so the Bun half changes both. This stays a draft until the Bun half is green on the preview build of this branch. Then: merge this, move Bun's WEBKIT_VERSION to the merged commit in that PR, and merge it as the next pin change. The branch is cut from the commit Bun pins (5a61071), so the preview carries this commit only. oven-sh/bun#48120 moves the pin again. I rebase when it lands.

Report. Bun 1.4.3 keeps a decorated field a field of the class (oven-sh/bun#40833), so decorated fields lost their position too: @dec f = new Error().stack printed at new C (f.ts:3:15) in 1.4.2 and prints at new C (f.ts:2:1) now. toMatchInlineSnapshot() in a field initializer cannot find its call. oven-sh/bun#45677 lists the plain case as a known bug.

Supersedes #435 (and its pin, oven-sh/bun#41694). That patch set location.line and called setEndPosition, which JSTokenLocation and FunctionMetadataNode no longer have, and it listed two frames for every static block. Positions need no change now: the stress test checks them in eval code and in functions that start in the middle of a line. I cannot close #435. Someone with the rights can.

Before and after, class W { port = missing.port } and class S { static { S.e = new Error() } static f = new Error() } in one file:

before  undefined is not an object (near '...(function () { })...')
            at new W (unknown:1:11)
        S.e:  at <anonymous> (6:30) | at <anonymous> (6:1)
        S.f:  at <anonymous> (6:1)
after   undefined is not an object (evaluating 'missing.port')
            at <instance_members_initializer> (3:10)
            at new W (unknown:1:11)
        S.e:  at <static_initializer> (6:30) | at <static_initializer> (6:36) | at <anonymous> (6:1)
        S.f:  at <static_initializer> (6:56) | at <anonymous> (6:1)

Static blocks: what was tried.

  • One function for each run of static fields and for each run of blocks, with the function of a run of blocks hidden. It gives one frame for every block. The first version of this PR did that. It costs about 19,000 instructions for each extra run when a class is first evaluated (1000 classes of static a; static {}: 90.9M to 109.7M), step-over in the debugger jumps from the first static block to the line after the class, and coverage function totals change. Removed.
  • A rule in StackVisitor: do not list a ClassFieldInitializerMode frame whose callee is a ClassStaticBlockMode frame. It gives one frame for every block with one static function, and stepping and coverage stay as they are. It costs every stack capture a check per frame: new Error() outside of any class went from 2,155 to 2,208 instructions. Not taken, because code with no class fields pays for it. The patch is 33 lines in StackVisitor if one frame per block is worth that.
  • Compiling a static block into the static function, as V8 does and as the comment in StaticBlockFunctionCallNode::emitBytecode (bug 245925) plans. It gives one frame, one function less per block, and no walk rule. It needs a var scope that is not a function. Not in this PR.

Measurements. Two release builds of Bun 2596ac5d39 on WebKit 5a61071 that differ only by this change (both have the two new identifiers). User-mode instructions from perf_event_open, least of three or five runs, slope between two loop counts.

what before after
new C(), 1 / 4 / 16 fields, JIT 3.497 / 3.496 / 6.000 3.499 / 3.502 / 5.994
new C(), 1 / 4 / 16 fields, useJIT=0 721.4 / 933.8 / 1801.5 721.7 / 934.1 / 1802.3
bytecode of class C { f = g() }: module, constructor, initializer 17, 7, 6 instructions 17, 7, 6 instructions
2000 classes, each evaluated once, no fields 74.79M 74.78M
the same, two instance fields and one new 186.61M 188.03M (+709 per class)
the same, one static field 106.56M 107.63M (+532 per class)
the same, one static block 167.70M 167.75M (+28 per class)
the same, block, field, block 248.14M 248.54M (+202 per class)
new Error() in a field initializer, create 2,345 2,431
the same, create and read .stack 19,557 23,624
new Error() in a constructor, create / create and read 2,155 / 23,162 2,154 / 23,160
serialized bytecode, class with 1 / 8 / 64 fields 1432 / 1864 / 5280 bytes 1488 / 1944 / 5584 bytes
serialized bytecode, block, field, block 1624 bytes 1664 bytes
size text of the release binary 134,412,254 134,413,022
  • Frames: four nested field constructions under seven callers list 14 frames before and 18 after. With the default limit of 10 the last frame kept moves from c6 to c2, which is what node keeps. An Error made outside an initializer lists the same frames.
  • The two names are members of CommonIdentifiers: two atoms per VM, no lookup per class.
  • Function.prototype.caller of a sloppy function called from an initializer is null: the initializer is strict code.
  • Debugger: Debugger.stepNext over static a = f(1); static { f(2); } static b = f(3); static { f(4); } static c = f(5); stops at the same five places before and after. A pause in a block shows the name <static_initializer> for the block and for its caller, where both were empty.
  • Coverage: bun test --coverage --coverage-reporter=lcov gives the same DA, FNF and FNH before and after for a class whose first static element throws ahead of blocks and fields that hold functions.
  • Also run with the patched jsc: the stress test with default options, --useJIT=0, --useDFGJIT=0 and eager tier-up thresholds. The test pins --showPrivateScriptsInStackTraces=false, because a build that shows hidden frames lists the hidden caller of a block.

Not changed here.

  • The frame of a constructor that the engine makes for a class without one still reads new W (unknown:1:11) (error: report default class constructor frames at the class definition bun#38507).
  • The wrapper of an async function that awaits is also Private (parser/Parser.cpp:2408), so an error in its parameter defaults has no frame.
  • Other operations that record no position of their own (ToString of a template substitution, a computed key, ==) are still reported at the statement in front of them outside of a field. In a field they are now reported at the field.

@github-actions

github-actions Bot commented Nov 7, 2026 •

Copy link
Copy Markdown

Preview build of 4a50303: autobuild-preview-pr-1003-4a503039

… stack trace

An error made while a class field is initialized was reported at the
constructor that ran the initializer, or at the `class` keyword for a
static field. The function that runs the initializers was
ImplementationVisibility::Private, and every stack walk leaves such a
frame out.

Under USE(BUN_JSC_ADDITIONS):

- The function that runs instance field initializers is Public and is
  named <instance_members_initializer>. The function that runs the
  static elements of a class is Public and is named
  <static_initializer>. V8 lists the same two names. The names are in
  CommonIdentifiers.
- A static block is a function of its own, and it takes the static name
  too. When every static element of a class is a block, the function
  that calls the blocks stays hidden, so such a block lists one frame.
- A field records expression info in front of its initializer and in
  front of the define of a named field. An operation that records no
  position of its own is then reported at its field, not at the field in
  front of it, and so is a define that throws.

Supersedes #435.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant