Skip to content

bake: construct the bun:app Response the same way with and without new - #44188

Open
robobun wants to merge 2 commits into
robobun/6c050347/bake-response-outside-dev-serverfrom
robobun/356a058b/bun-app-response-call-without-new
Open

robobun wants to merge 2 commits into
robobun/6c050347/bake-response-outside-dev-serverfrom
robobun/356a058b/bun-app-response-call-without-new

Conversation

@robobun

@robobun robobun commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #44166.

A maintainer decision is open: this PR keeps Response(...) of bun:app without new a constructor call. The global Response throws a TypeError. See Notes.

Problem

  • Response(obj) of bun:app without new crashes for every object body: Segmentation fault at address 0x0, debug panic: null reference produced. In bun dev, one page that returns Response(<jsx />) ends the server.
  • JSBakeResponseConstructor::call (src/jsc/bindings/JSBakeResponse.cpp:228) passes nullptr as the out-pointer for the JSX flag. BakeResponseClass__constructForSSR (src/runtime/webcore/BakeResponse.rs:68) writes through it.

Fix

  • construct and call share one C++ function, constructBakeResponse. It does the JSX check and passes a bool to Rust. Response(<jsx />) now does what new Response(<jsx />) does.
  • This PR deletes JSValue::is_jsx_element and JSC__JSValue__isJSXElement: no caller is left.
  • Verified: test/bake/dev/response-to-bake-response.test.ts (3 new cases) and react-response.test.ts (2 new cases). All 5 fail on bake: return undefined for an unset AsyncLocalStorage instance #44166.
  • Self-reviewed: 13 concerns raised, 12 addressed. Not done: tracking issues for the defects that Notes lists.

Background

  • bun:app exports the Response that server components get in place of the global one. The parser rewrites the identifier.
  • A JSX body makes the Response a React element that renders the JSX.
  • Considered a valid pointer in call. Response(<jsx />) would return a Response that React cannot render.

Downsides

  • Response(obj) and Response(<jsx />) return a Response where they crashed. Response("x") does not change.
  • .text grows 256 B, because call gains the JSX path.
  • Instructions per new Response("x") of bun:app: 3363 to 3395 before, 3358 to 3380 after.
Notes

The open decision: construct or throw

This PR constructs. Reasons:

  • The call form was written to construct. JSBakeResponseConstructor::call builds an instance, and the doc comment of response_ref in src/js_parser/p.rs names the syntax return Response(<jsx />, {...}). Both are from ssg 3 #22138.
  • Response("x"), Response(), Response(null) and Response(123) of bun:app return a Response on every release build since 1.3.14. No result that works today changes.

Reasons to throw TypeError: Response constructor cannot be invoked without 'new', as the global class and Node do:

  • The call form with an object body never worked in a release.
  • packages/bun-types/globals.d.ts declares only the new signature.
  • The parser puts this class in place of the global identifier. The same source line throws when it runs as is, and constructs when it is a server component.
  • It is the smaller change, and a later move from throw to construct breaks no program.

If the decision is to throw: call throws, the 5 new cases assert the error, the comment in p.rs gains new, and construct keeps the shared function alone.

Other designs

  • A valid pointer in call and no other change (2 lines). The crash goes away, but call ignores the flag, so Response(<jsx />) has type: null and no streaming check.
  • A Response class written as a JS builtin over the generated constructor. It removes the native code that both crashes were in. It also changes dev server behaviour and is a much larger change, so it is not part of a crash fix.

How it was found, and who reaches it

  • An automated sweep of native functions found it. No user report exists.
  • The case a user reaches: a page under bun dev that forgets new. The first request of that page ends the dev server for every page.
  • A plain script reaches it with import { Response } from "bun:app". Output of bun build --server-components reaches it from source that only names the global Response (cjs and iife today, esm after js_parser: alias the server-components Response import only under hot reloading #44167).
  • The same code is in the Zig original (ssg 3 #22138), so this is not a regression.

Behaviour, Response of bun:app

Call #44166 (release) This PR
Response("x"), Response() a Response (debug: panic: null reference produced) a Response
Response({ a: 1 }) segfault at 0x0 a Response, body [object Object]
Response(<jsx />, { status: 201 }) segfault at 0x0 status 201, type() returns the element
Response(body), the $$typeof getter of body throws segfault at 0x0 the error propagates
page returns Response(<h1 />, { status: 201 }) in bun dev the dev server ends 201, headers, markup
the same page with export const streaming = true the dev server ends the error that new Response(<jsx />) gives

Measurements

Release builds (ThinLTO, linux x64) of #44166 (db037a0) and of this branch, from one checkout path.

  • Instructions per call, from the entry of the host function to its return, callees included. Main thread, counted with a ptrace single-step counter, 10 runs of 100 calls, per-run mode, then min / median / max over the runs. valgrind, perf and bloaty are not available in the build container (perf_event_open is not permitted). ASLR cannot be turned off there. That is the spread between runs.

    Call (bun:app) bake: return undefined for an unset AsyncLocalStorage instance #44166 This PR
    new Response("x") 3363 / 3375 / 3395 3358 / 3364 / 3380
    new Response({ a: 1 }) 4897 / 4919 / 4929 4861 / 4873 / 4903
    Response("x") 3343 / 3349 / 3375 3342 / 3354 / 3364
  • Function sizes (nm -S): BakeResponseClass__constructForSSR 408 -> 199 B. JSBakeResponseConstructor::construct 1311 -> 904 B. ::call 252 -> 494 B. wrapInnerComponent was inline in construct and is now one copy of 621 B. isJSXElement 310 -> 487 B, and reactLegacyElementSymbol (203 B) is inline in it now.

  • Binary (size -A, ls -l): .text 58,143,477 -> 58,143,733 B. bun-profile 169,905,600 -> 169,903,232 B. Stripped bun 80,827,976 B in both.

  • Startup: both Zig::GlobalObject constructors keep their size (708 B and 717 B).

  • Codegen: cppbind rows 173 -> 173. JS-to-native host functions 368 -> 368.

  • Cells per JSX construction in the dev server (heapStats, 2000 kept alive): 16.01 for new on bake: return undefined for an unset AsyncLocalStorage instance #44166, 16.01 for new on this PR, 16.01 for the call form on this PR.

Suites

  • Debug + ASAN build of bake: return undefined for an unset AsyncLocalStorage instance #44166: the 3 new spawn cases and the 2 new dev server cases fail. The 8 and 11 cases that exist pass.
  • Debug + ASAN build of this branch, with BUN_JSC_validateExceptionChecks=1 and LeakSanitizer as the ASAN lane sets them: response-to-bake-response.test.ts 11 of 11. react-response.test.ts 13 of 13 (exception checks only, the file is in no-validate-leaksan.txt).
  • Also on this branch: production.test.ts 13 of 13, ssg-pages-router.test.ts 9 of 9, request-cookies.test.ts 2 of 2.
  • bun run rust:check-all: 12 of 12 targets.
  • windows-x64: this revision was not run there. An earlier revision with the same C++ function and the same Rust signature passed its call form cases on a windows-x64 debug build. BakeResponseClass__constructForSSR keeps its calling convention. Only the type of one parameter changes, from int* to bool.

Other open PRs on this code

Defects that this PR does not change

Each one gives the same result on #44166 and on this branch. None is a crash.

  • In bun dev, Response.redirect(url, 301) (also 307, 308) answers 200 with the target page and no Location. hmr-runtime-server.ts tells a redirect from a render with status !== 302.
  • instanceof the bun:app class is false for a fetch() result, for Response.json(), for clone() and for a global instance. Bun.inspect() of a bun:app Response prints <null />.
  • A call at the top level of a page module runs before the AsyncLocalStorage has a store. Response.redirect(), Response.render() and a JSX body then throw TypeError: store value must have a "streaming" field. The call form with a JSX body now does the same.
  • The global object has one AsyncLocalStorage slot. A second dev server in the same process replaces the storage of the first one.

[human-review] gate passed · iteration 0 · 5 files touched

fails on main (without fix)
ASAN without fix: 5 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bake/dev/react-response.test.ts test/bake/dev/response-to-bake-response.test.ts
bun test v1.4.3 (367d939d9)

test/bake/dev/react-response.test.ts:
Dev server testing directory: /tmp/bun-dev-test-yvotwj
bun add v1.4.3 (367d939d9)
Resolving dependencies
Resolved, downloaded and extracted [2]
Saved lockfile

installed react@0.0.0-experimental-603e6108-20241029
installed react-dom@0.0.0-experimental-603e6108-20241029
installed react-server-dom-bun@0.0.0-experimental-603e6108-20241029
installed react-refresh@0.0.0-experimental-603e6108-20241029

6 packages installed [783.00ms]
bun install v1.4.3 (367d939d9)

Checked 6 installs across 7 packages (no changes) [118.00ms]
�[0;30mdev|�[0m Started development server: http://localhost:46417
�[0;30mdev|�[0m �[32mBundled page in 2436ms�[0m�[2m:�[0m pages/index.tsx �[2m+ 2 more�[0m
�[0;30mdev|�[0m �[0m�[1m1 |�[0m �[0m�[35mexport�[0m �[0m�[35mconst�[0m streaming = �[0m�[33mfalse�[0m�[0m�[2m;�[0m
�[0;30mdev|�[0m �[0m�[1m2 |�[0m �[0m�[35mexport�[0m �[0m�[35mconst�[0m mode = �[0m�[32m"ssr"�[0m�[0m�[2
... (truncated)

release without fix: 8 FAILED
bun test v1.4.3-canary.1 (367d939d9)

test/bake/dev/react-response.test.ts:
Dev server testing directory: /tmp/bun-dev-test-MXPOji
bun add v1.4.3-canary.1 (367d939d9)
Resolving dependencies
Resolved, downloaded and extracted [0]
Saved lockfile

installed react@0.0.0-experimental-603e6108-20241029
installed react-dom@0.0.0-experimental-603e6108-20241029
installed react-server-dom-bun@0.0.0-experimental-603e6108-20241029
installed react-refresh@0.0.0-experimental-603e6108-20241029

6 packages installed [60.00ms]
bun install v1.4.3-canary.1 (367d939d9)

Checked 6 installs across 7 packages (no changes) [2.00ms]
�[0;30mdev|�[0m Started development server: http://localhost:40893
�[0;30mdev|�[0m �[32mBundled page in 72ms�[0m�[2m:�[0m pages/index.tsx �[2m+ 2 more�[0m
�[0;30mdev|�[0m �[0m�[1m1 |�[0m �[0m�[35mexport�[0m �[0m�[35mconst�[0m streaming = �[0m�[33mfalse�[0m�[0m�[2m;�[0m
�[0;30mdev|�[0m �[0m�[1m2 |�[0m �[0m�[35mexport�[0m �[0m�[35mconst�[0m mode = �[0m�[32m"ssr"�[0m�[0m�[2m;�[0m
�[0;30mdev|�[0m �[0m�[1m3 |�[0m 
�[0;30mdev|�[0m �[0m�[1m4 |�[0m �[0m�[35mexport�[0m �[0m�[35mdefault�[0m �[0m�[35masync�[0m �[0m�[35mfunction�[0m IndexPage() {
�[0;30mdev|�[0m     �[39m�[
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bake/dev/react-response.test.ts test/bake/dev/response-to-bake-response.test.ts
bun test v1.4.3 (367d939d9)

test/bake/dev/react-response.test.ts:
Dev server testing directory: /tmp/bun-dev-test-voGFRi
bun add v1.4.3 (367d939d9)
Resolving dependencies
Resolved, downloaded and extracted [0]
Saved lockfile

installed react@0.0.0-experimental-603e6108-20241029
installed react-dom@0.0.0-experimental-603e6108-20241029
installed react-server-dom-bun@0.0.0-experimental-603e6108-20241029
installed react-refresh@0.0.0-experimental-603e6108-20241029

6 packages installed [231.00ms]
bun install v1.4.3 (367d939d9)

Checked 6 installs across 7 packages (no changes) [93.00ms]
�[0;30mdev|�[0m Started development server: http://localhost:37229
�[0;30mdev|�[0m �[32mBundled page in 1842ms�[0m�[2m:�[0m pages/index.tsx �[2m+ 2 more�[0m
�[0;30mdev|�[0m �[0m�[1m1 |�[0m �[0m�[35mexport�[0m �[0m�[35mconst�[0m streaming = �[0m�[33mfalse�[0m�[0m�[2m;�[0m
�[0;30mdev|�[0m �[0m�[1m2 |�[0m �[0m�[35mexport�[0m �[0m�[35mconst�[0m mode = �[0m�[32m"ssr"�[0m�[0m�[2m
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 5917ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/23] cxx obj/unified/UnifiedSource-src_jsc_bindings-1.cpp.o
[2/23] gen generated_host_exports.rs
generated_host_exports.rs: 121 exports (host=5, lazy=10, generic=106, rust=0); 247 extern-C blocks audited
[3/22] gen cpp.rs (cppbind)
[4/21] rustc bun_jsc 
[5/21] rustc bun_ast_jsc 
[6/21] rustc bun_sys_jsc 
[7/21] rustc bun_patch_jsc 
[8/21] rustc bun_semver_jsc 
[9/21] rustc bun_bundler_jsc 
[10/21] rustc bun_css_jsc 
[11/21] rustc bun_js_parser_jsc 
[12/21] rustc bun_sourcemap_jsc 
[13/21] rustc bun_install_jsc 
[14/21] rustc bun_http_jsc 
[15/21] rustc bun_sql_jsc 
[16/21] rustc bun_runtime 
[17/21] link bun-profile
ld.lld: warning: Linking two modules of different target triples: 'obj/vendor/mimalloc/src/static.c.o' is 'x86_64-pc-linux-gnu' whereas '../../../../root/.bun/build-cache/webkit-f20ce7744553c910-lto/lib/libJavaScriptCore.a(UnifiedSource-bytecompiler-1.cpp.o at 42486912)' is 'x86_64-unknown-linux-gnu'


ld.lld: warning: Linking two modules of different target triples: 'obj/codegen/GeneratedSS
... (truncated)
diff hotspot
src/jsc/JSValue.rs                              |  9 ---
 src/jsc/bindings/JSBakeResponse.cpp             | 81 +++++++++++--------------
 src/runtime/webcore/BakeResponse.rs             | 32 ++++------
 test/bake/dev/react-response.test.ts            | 53 ++++++++++++++++
 test/bake/dev/response-to-bake-response.test.ts | 38 ++++++++++++
 5 files changed, 137 insertions(+), 76 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                             reads  edits  tests
src/jsc/JSValue.rs                                   1      1     27
src/jsc/bindings/JSBakeResponse.cpp                  2      2     29
src/runtime/webcore/BakeResponse.rs                  3      2     28
test/bake/dev/react-response.test.ts                 2      3     14
test/bake/dev/response-to-bake-response.test.ts      2      2     18

…ll pointer

The call form of the bun:app Response handed a null out-pointer for the
JSX flag to the Rust constructor. The constructor wrote through it for
every object body.

The constructor and the call form now share one C++ function. It finds
out if the body is a JSX element and passes the answer to Rust by value.
Response(<jsx />) wraps the element as new Response(<jsx />) does.
@robobun

robobun commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator Author

Status: the fix is pushed. It waits for CI and for #44166.

How I reproduced it (every release since 1.3.14, and main a4f1429):

// bun nonew.mjs
import { Response } from "bun:app";
console.log(String(Response({ a: 1 })));
  • Before: Segmentation fault at address 0x0. A debug build stops with panic: null reference produced.
  • With this PR: [object Response].
  • In the dev server, a page that returns Response(<h1>Hello World</h1>, { status: 201 }) ended the server on its first request. With this PR it answers 201.

PR: #44188, on top of #44166.

@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 reviewed this PR and didn't find any bugs. Since the description leaves the construct-vs-throw semantics of Response(...) without new as an open maintainer decision, a human look is still needed to settle that before merging.

What was reviewed:

  • Diffed the old construct/call paths against the shared constructBakeResponse: the JSX gate is equivalent (isJSXElement already returns false for non-objects, and isJSX implies argumentCount() > 0), argument(1) matches the old undefined default, and the new RETURN_IF_EXCEPTION after wrapInnerComponent closes a gap the old construct had.
  • Checked the int* -> bool FFI change on both sides of BakeResponseClass__constructForSSR (C++ extern and jsc_host_abi! export agree; bool is ABI-safe under both extern "C" and sysv64), and grepped for stale callers of is_jsx_element / JSC__JSValue__isJSXElement — none remain, including .classes.ts inputs.
  • The JSBakeResponse cell left with null m_ctx on the Rust error path is the pre-existing pattern from construct, not introduced here.
Extended reasoning...

The change touches src/jsc/bindings/JSBakeResponse.cpp, src/runtime/webcore/BakeResponse.rs, and src/jsc/JSValue.rs to fix a null-pointer write in the call form of the bun:app Response by unifying construct and call behind one C++ helper and changing an FFI out-param to a by-value bool, plus five new tests across the two bake test files. It touches no auth, crypto, or injection surface; the security-relevant piece is exception-scope correctness after user-observable $$typeof getter access, which is handled with RETURN_IF_EXCEPTION. The code is small and behavior-preserving on the new path, and the new tests cover both entry points and the streaming-enabled error. Defer rather than approve because the PR itself flags an unresolved user-facing API decision (whether the call form should construct, as implemented, or throw like the global Response), which is a maintainer judgment call rather than a correctness question.

@robobun

robobun commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator Author

No change follows from this review.

The PR waits for three things:

  • CI on this branch.
  • bake: return undefined for an unset AsyncLocalStorage instance #44166, which is the base of this PR.
  • The maintainer decision at the top of the description: Response(...) of bun:app without new constructs (this PR), or throws a TypeError as the global class does. If the decision is to throw, I change call and the 5 new cases in this PR.

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.

2 participants