Repository navigation
Conversation
…onse return value throws When a fetch handler returns a value that is not a Response, the server prints "Expected a Response object, but received '<value>'". Inspecting the value runs user code ([inspect.custom], toString). When that code threw, the error was dropped and the exception stayed pending on the VM after the request. The next JavaScript to run met it: a timer callback that had never run aborted the process, and a request on another connection skipped its handler and got a 500 with this exception through error(). render_missing_invalid_response now formats the value with Formatter::format_value, which returns the error. When it fails, the server prints the short diagnostic and reports the exception as uncaught at that point. An async handler already behaved this way. The console hook for Response, Request, Blob, BuildArtifact, S3Client and Archive returned Ok with the exception pending when write_format failed. It now returns the error, so a Request with a throwing value in its params takes the same path.
|
Status: ready for review. How I reproduced it:
The scripts are in the Notes block of the description. Since 716dcfe the exception goes to the request's error path ( |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Essentials Run ID: 📒 Files selected for processing (2)
Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review. WalkthroughThe change propagates pending JavaScript exceptions from runtime value formatting, adds fallback reporting for invalid responses, and tests synchronous, asynchronous, concurrent, and timer-ordered request scenarios. ChangesRuntime inspection error handling
Suggested reviewers: Priority: ➖ Normal Merge Risk: ⚪ Minimal · up to No concrete merge-blocking risk remains in the changed invalid-response handling. 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
…lippy::needless_pass_by_value)
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline findings, I also checked the teardown path where an uncaughtException listener invoked by fold at src/runtime/server/RequestContext.rs:824 calls server.stop(true) — the existing RequestContextRef guard and the is_aborted_or_ended() re-check at line 832 already cover that re-entry — and the remaining FetchHeaders branch in console_print_runtime_object_inner, which propagates via ? and does not share the swallowed-write_format pattern.
Extended reasoning...
Two findings were posted inline (the behavioral change to exit 1 for listener-less servers on the sync path, and the serial subprocess tests). Separately I traced the re-entry from crate::dispatch::fold into a user uncaughtException listener that tears down the server: the request-context liveness guard and the post-diagnostic is_aborted_or_ended() check predate this PR and handle synchronous abort/end, so the new fold call does not introduce a use-after-free there. I also confirmed the formatted helper in src/runtime/jsc_hooks.rs covers all six write_format branches and that the seventh (FetchHeaders) already uses ?-propagation, so no sibling site in that function is left with the old pattern.
One verified lower-impact observation (a convention, logging or cleanup point) was not posted.
An exception thrown while the server inspects a non-Response return value now goes to run_error_handler, like an exception the handler threw: error() runs and the request gets a 500. The previous commit reported it as uncaught, which ends a process that has no uncaughtException listener. The new tests run concurrently. Each one spawns its own process.
There was a problem hiding this comment.
The latest push addresses both earlier inline comments (the inspection error now goes to this request's error() via run_error_handler, and the new block is describe.concurrent); I found no bugs in this revision, but since it changes request-lifecycle exception routing in Bun.serve, a human look is still worthwhile.
What was reviewed:
RequestContext.rs:take_errorclears the pending exception beforerun_error_handlerenters JS; theis_aborted_or_ended()early return drops the already-cleared error, so nothing stays pending on the VM.jsc_hooks.rs:rethrow_pendingonly propagates whenhas_exception()is true, so sink-onlyfmt::Errors (closed pipe) keep the old swallow behavior.- Note the PR description's "Decision" paragraph (report as uncaught via
fold, exit 1 without a listener) no longer matches the code, which answers 500 througherror(); worth updating before merge.
Extended reasoning...
Overview
The PR touches three files: src/runtime/server/RequestContext.rs (render_missing_invalid_response now formats the returned value explicitly, captures a thrown inspection exception with take_error, and routes it to run_error_handler after the abort check), src/runtime/jsc_hooks.rs (the console hook for Response/Request/BuildArtifact/Blob/S3Client/Archive now returns Err(JsError::Thrown) when write_format failed with an exception pending instead of returning Ok(true)), and test/js/bun/http/serve.test.ts (a describe.concurrent block with three spawned-process tests covering sync/settled/pending/Request-return variants, cross-connection isolation, and the timer-callback crash).
Security risks
None specific to this change. The code paths run user JS during formatting, which was already the case; the fix makes an exception raised there attributable to the originating request rather than leaking into unrelated JS. No new input parsing, no credential or auth handling.
Level of scrutiny
Moderate. The diff is small and self-contained, but it changes observable behavior of the HTTP server (a request whose return value cannot be inspected now gets a 500 through error() rather than 204 plus a leaked exception) and modifies exception propagation in a shared console hook used by console.log/util.inspect. I checked that take_error consumes the pending exception before any JS runs, that the early is_aborted_or_ended() return leaves the VM clean, and that rethrow_pending is gated on has_exception() so pure sink failures are unchanged. The run_error_handler path mirrors what handle_reject already does for a rejected handler promise, so it follows an established pattern.
Other factors
Both inline comments from the earlier review were addressed in commit 716dcfe. The PR description still describes the earlier design (crate::dispatch::fold, uncaught, exit 1) and should be updated to match the merged behavior. The robobun evidence block reports no local test proof, deferring to CI, so a maintainer should confirm the new tests pass on the CI lanes before merging.
|
The description is current as of 716dcfe. It has no "Decision" paragraph anymore: the Fix section describes the |
Problem
fetchhandler returns a non-Responsevalue whose inspection throws, such as a throwing[util.inspect.custom]. Bun 1.4.2 aborts (panic(main thread): abort() called) at the next new timer callback, or answers another connection's request with a 500 that carries this exception. Debug builds:panic: a task returned Ok with a JS exception pending.render_missing_invalid_response(src/runtime/server/RequestContext.rs:804) prints the value through aDisplayadapter that can only returnfmt::Error. The JS exception stays pending on the VM until other JavaScript runs.Fix
Formatter::format_value, which returns theJsResult. On failure, printExpected a Response objectand pass the exception torun_error_handler, like an exception the handler threw.src/runtime/jsc_hooks.rs:2026) returnedOkwith the exception pending forRequest,Responseand four more types. It now returns the error. A returnedRequestwith a throwing value inparamsreached it.test/js/bun/http/serve.test.tsfail on canary c6b7fcb and on a debug build of main. Also ran the inspect, console and routes suites. Self-reviewed: 6 concerns raised, 6 addressed.Background
Responsereturn value,Bun.serveprints a diagnostic and answers 204.Err(JsError)means an exception is pending on the VM. JavaScriptCore must not enter JavaScript while one is pending.run_error_handleris the path of an exception thefetchhandler throws. It callserror()if there is one, and answers 500.Notes
Repro, process abort (1.4.2 and canary c6b7fcb, 3 of 3). The client must be another process, so that the timer callback is the next JavaScript the server runs.
curlit once. 1.4.2 answers 204, then:Repro, error delivered to another request (1.4.2, 3 of 3). Open two connections, then write
GET /badandGET /goodin the same tick./badreturns the value above. Result:/badgets 204,/goodgets500 error-handler: inspector threw for /bad, and thefetchhandler never runs for/good. With this change/badgets the 500 and/goodgets 200.What each build does with the throwing value, by the kind of handler:
error()error()on_resolve)uncaughtExceptionuncaughtExceptionerror()"Exception left pending" means that the next JavaScript meets it. On 1.4.2 that is an abort at a timer callback that never ran before, or a 500 for another request, or an
uncaughtExceptionif other JS runs first. On 1.3.13 another request gets the 500 (3 of 3), and a timer callback reports the exception as uncaught without an abort. The sync path prints this diagnostic since 1.4.0 (#33120, commit 73dad76 is in thebun-v1.4.0tag). I ran 1.4.2 and 1.3.13. On every build the diagnostic line was cut off afterbut received '.Why
error()and not uncaught. The first version of this PR reported the exception withcrate::dispatch::fold, which is what the pending-promise path did by accident. A review pointed out that this ends a process that has nouncaughtExceptionlistener, and that only the pending-promise path had that behavior before. The exception belongs to one request, and this file already sends such errors torun_error_handler: a handler throw, a rejected promise, and an unsendable status (reject_unsendable_response, called from the same two places as this function). Without anerror()hook the request gets the default 500 page and the error is printed, as for a handler throw.A realistic trigger: a handler that returns a Mongoose document by mistake. With
toObject: { virtuals: true }and a virtual getter that throws,util.inspect(doc)throws (checked with mongoose 9.10.1; on 1.4.2 the exception leaks the same way).Values tried against the unfixed debug build:
[util.inspect.custom], sync handler, settled promise, pending promise: panic.Object.assign(new String("q"), { toString() { throw ... }, [Symbol.toPrimitive]() { throw ... } }): panic. The same fix covers it.BunRequestwithreq.params.x = { [inspect.custom]() { throw ... } }: panic, and still a panic with theRequestContext.rschange alone.Request::write_formatreturnsfmt::Errorfor the JS exception and the console hook dropped it.test_runner/pretty_format.rsalready turns that result intoErr(JsError::Thrown). The hook now does the same. A sink error with no exception behind it is still ignored, soconsole.logto a closed pipe does not start to throw.[Getter]and does not call it.ProxywhoseownKeysthrows: the formatter clears that exception itself and prints{}.Error.prepareStackTraceduringwrite_trace: no leak.console.log(req)with the poisonedparamsthrows the inspector's error on 1.4.2 and on this branch. Release behavior does not change there. JavaScriptCore found the pending exception when the host function returned.Also run on this branch (debug build with ASAN, and again with
BUN_JSC_validateExceptionChecks=1):error()hook and nouncaughtExceptionlistener: 500Something went wrong!, the error is printed, the server keeps serving.error()throws: default 500.error()returns another throwing value, directly or from a pending promise: default 500,error()is not called twice.error()callsserver.stop(true): the client getsECONNRESET, no ASAN report.render_missing_invalid_responsealready checksis_aborted_or_ended()after the diagnostic, because the formatter can run user code.development: true: 500 with the development error page.test/js/bun/http/serve.test.tswhole file: 326 pass, 2 fail. The two fail in my container for reasons that do not involve this change: "should use correct error when using a root range port(error message with Bun.serve for EACCESS is shown as EADDRINUSE #7187)" (the container runs as root, so the bind succeeds) and "only serves /bun:info to loopback clients in development mode" (the egress proxy of the container answers the request to the external address).test/js/web/fetch/response.test.ts: "handle stack overflow" times out at 5 s on the debug build. It does not print or inspect anything.Thrown values take another path. This PR covers a value that the handler returns. A value that a route throws with no
error()handler is printed by the error printer (print_error_instance_body,src/jsc/VirtualMachine.rs). On main that printer discards the formatter's error. #36921 changes that line.throw { status: 400, doc }), error printer: clear the pending exception when formatting a non-Error uncaught value throws #36921 alone is enough.throw reqandPromise.reject(req)with such a value inreq.params, both PRs are needed. This PR makes theRequestprinter return the error, and error printer: clear the pending exception when formatting a non-Error uncaught value throws #36921 makes the error printer take it.ASSERTION FAILED: Unexpected exception observed. With error printer: clear the pending exception when formatting a non-Error uncaught value throws #36921 alone,throw req,Promise.reject(req)andthrow [req]still hit it. With both PRs, each shape is clean.Displayadapter inrender_missing_invalid_response, so theRequestContext.rschange here is still needed with it.Not in this PR: a check in the request dispatcher that folds or asserts on an exception that is still pending when a handler's frame returns. It would stop any later leak of this kind from reaching another request. It is a wider change than this fix. The
write_formatfamily returnsfmt::Errorfor a JS exception. A typed error there would remove thehas_exception()check in the console hook and the four copies inpretty_format.rs. That touches eight files and is a separate refactor.no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/http/serve.test.ts