jsc: report the flatten exception from JSString conversions; answer onResolve once - #37914
jsc: report the flatten exception from JSString conversions; answer onResolve once#37914robobun wants to merge 1 commit into
Conversation
JSC__JSString__toZigString leaves the exception thrown by a failed rope flatten (JSC's "Out of memory" RangeError) pending on the VM and returns an empty string. JSString::to_zig_string and the helpers built on it (get_zig_string, view, to_slice, to_slice_clone) treated that as success, so callers carried on with an empty string while an exception was pending. Wrap the call in from_js_host_call_generic, as JSValue::to_zig_string already does, and propagate the error at every caller. In JSBundlerPlugin__onResolveAsync the pending exception also rejected runOnResolvePlugins, whose rejection handler answered the same resolve a second time through addError. Take the exception there and answer the resolve once, with it as a build error.
|
Warning Review limit reached
Next review available in: 3 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (20)
Comment |
|
Status: fix is up in this PR. Reproduced on the current canary (da3851e) and on a debug build at 9a543cc with an onResolve plugin returning Tests: |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it changes a core JSC binding signature (JSString::to_zig_string → JsResult) across 16 caller files and adds bespoke double-answer handling in JSBundlerPlugin__onResolveAsync (the segfault fix), a human look would still be worthwhile.
What was reviewed:
- Every updated caller propagates via
?in aJsResultcontext, or returns the pendingJsErrordirectly (JSGlobalObject.rs,validators.rs) — no second exception layered on top. - The two
clear_exception()calls (VirtualMachine.rs,ipc.rs) match existing local fallback patterns for the same "could not read" case. onResolveAsync's newErrarm takes the exception beforeon_resolve_async, sorunOnResolvePluginsno longer sees a pending throw andaddErroris not called a second time.- Tests wire error/close to reject, assert no empty frames precede
"done", and the rope construction is length-checked so the flatten is guaranteed to fail.
Extended reasoning...
Overview
This PR makes JSString::to_zig_string (and its wrappers get_zig_string, view, to_slice, to_slice_clone) return JsResult so that JSC's rope-flatten RangeError is observed rather than swallowed as an empty string with a pending exception. It updates 16 caller files across the runtime (bundler plugins, WebSocket server, HTTP/2, DNS, UDP, IPC, semver, zlib, util, valkey, sinks, test runner) and adds targeted tests for the two most consequential paths: bundler onResolve (the segfault) and WebSocket send/publish (the empty-frame leak).
The critical piece is JSBundlerPlugin__onResolveAsync: instead of .expect() on to_slice_clone, it now matches on the result, and on Err takes the exception via take_exception and answers with ResolveValue::Err(plugin_msg_from_js(...)) — the same shape JSBundlerPlugin__addError produces. This prevents the double-answer (once as Success with an empty string, then again as Err when the pending exception rejects runOnResolvePlugins) that caused segfaults in release builds.
Security risks
None identified. The change tightens error handling — inputs that previously silently degraded to empty strings now throw. No new trust boundaries, no parsing of untrusted data, no auth/crypto paths touched.
Level of scrutiny
High. This is a signature change to a core JSC string-conversion helper with ~30 call sites across memory-safety-adjacent subsystems (bundler thread synchronization, WebSocket frame writes, HTTP/2 header encoding). The bundler double-answer fix is the difference between a crash and a build error, and the take_exception placement is load-bearing. The two clear_exception() sites deserve a maintainer's eye given REVIEW.md's general guidance against clearing — both are justified (non-throwing fallback contexts matching existing local patterns in the same file), but that's exactly the kind of judgment call a human should sign off on.
Other factors
- The mechanical
?propagations are straightforward and each caller is already in aJsResult-returning host function. - The
JsError-returning helpers inJSGlobalObject.rsandvalidators.rscorrectly return the flatten error directly rather than building anINVALID_ARG_TYPEon top of a pending exception. - Test coverage is good: bundler tests assert
build.success === falseand the specific error location; the WebSocket test asserts both that all five variants throwRangeError: Out of memoryand that only the sentinel"done"frame arrives. Failure paths (client error/close) reject the awaited promise. - The PR description documents cross-target
cargo checkfor the twocfg(windows)sites andBUN_JSC_validateExceptionChecks=1runs. - No prior human reviews on the timeline to consider.
Problem
pathornamespacestring JSC cannot flatten makes a debug build abort withASSERTION FAILED: Unexpected exception observed ... Error Exception: Out of memory/!exception()inJSC::ExceptionScope::releaseAssertNoException()oncejsBundlerPluginFunction_onResolveAsyncreturns.runOnResolvePlugins(src/js/builtins/BundlerPlugin.ts), whose rejection handler callsaddErrorfor the same request, so the oneResolveis answered twice (Successwith an empty string, thenErr). Running the repro below 40 times in a loop on the current canary (da3851e) segfaults or panics; a single run reportssuccess: trueor the error depending on timing.JSC__JSString__toZigString(src/jsc/bindings/bindings.cpp) callsJSString::value(), which on a failed flatten throws and returns the null string; the shim hands back an emptyZigStringwith the exception still pending, andJSString::to_zig_string(src/jsc/JSString.rs) never checks for it. Soget_zig_string,view,to_sliceandto_slice_cloneonJSString(andJSValue::to_slice_clone, which goes through it) reported success with an empty string. The.expect("Unexpected: path is not a string")inJSBundlerPlugin__onResolveAsync(src/runtime/api/JSBundler.rs) never fired because the helper never reported the failure.ws.send(str)/ws.publish(topic, str)/server.publish(topic, str)wrote an empty text frame to the peer before the error surfaced, andBun.semver,zlib.crc32,util.parseEnv,dns.lookup,udpSocket.send,http2header encoding andFileSink/ArrayBufferSink.writeall carried on with an empty string.JSValue::to_zig_string(theJSValueflavour of the same helper) already handled this correctly, soJSValue::get_zig_string/to_slicecallers were not affected.Fix
JSString::to_zig_stringwraps the FFI call infrom_js_host_call_generic, exactly asJSValue::to_zig_stringdoes, and returnsJsResult<()>;get_zig_string,viewandto_slicebecomeJsResulttoo andto_slice_clonepropagates instead of always returningOk. This is correct because flattening a rope is a fallible JSC operation and this wrapper is the layer that turns a pending exception into aJsResult; a C++ shim leaving the exception on the VM is the normal JSC convention.?(their callers already unwind on a pending exception), theJsError-returning helpers inJSGlobalObject.rs/validators.rsreturn the pending exception instead of building a second error on top of it, and the two non-throwing contexts keep their existing behaviour for "could not read the string" (wrap_unhandled_rejection_error_for_uncaught_exceptionfalls through to its existingundefinedmessage,import_windows_socket_payloadclears and returnsNone, matching theErrarm a few lines above it).JSBundlerPlugin__onResolveAsyncturns anErrintoResolveValue::Err(plugin_msg_from_js(...)), which is whatJSBundlerPlugin__addErrordoes for an exception thrown by the callback itself, and still reacheson_resolve_async. Taking the exception (take_exception) is what removes the second answer: the host function returns with nothing pending, sorunOnResolvePluginsno longer rejects. The build fails witherror: Out of memoryat the importing file, and the nextBun.buildin the process works. This keeps the JSBundler.rs change to the one branch since bundler: touch outstanding plugin requests through their pointer, not as &mut #37746 / bundler: stop the plugin hops from reaching back into the pass that posted them #37709 restructure these thunks; bundler: fail the build instead of panicking when onLoad contents cannot be converted #37858 does the equivalent for the onLoad thunk and explicitly leaves this one alone.test/bundler/bundler_plugin.test.ts(plugin/ResolvePathStringConversionThrows,plugin/ResolveNamespaceStringConversionThrows) andtest/js/bun/websocket/websocket-server.test.ts(send()/publish() with a string that cannot be flattened throw and send nothing, which coversws.send,ws.sendText,ws.publish,ws.publishTextandserver.publish). Without the src change, a debug build aborts on the assertion in all three tests; the release canary fails the path test (success: true; the namespace variant depends on which of the two answers the bundler thread sees first) and the websocket test (five empty frames delivered before"done"). With it, all pass, also underBUN_JSC_validateExceptionChecks=1.bundler_plugin.test.tsandwebsocket-server.test.ts,test/cli/install/semver.test.ts,test/js/node/zlib/zlib.test.js,test/js/node/util/util.test.js,test/js/bun/util/arraybuffersink.test.ts,test/js/bun/test/expect-extend-preload.test.ts,test/js/bun/udp/udp_socket.test.ts,test/js/node/http2/node-http2.test.js, and the node parallel tests forzlib.crc32,util.parseEnvand unhandled rejections.cargo checkofbun_runtimeforx86_64-pc-windows-msvcandaarch64-apple-darwincovers the twocfg(windows)call sites;cargo fmtand clippy are clean on the touched files.Background
JSRopeString) and only builds the flat character buffer when something needs to read it ("flattening",JSRopeString::resolveRope). Building a rope of length2^31 - 1only allocates tree nodes (the repro peaks around 30 MB RSS), while flattening it as 16-bit text needs a buffer larger thanWTF::StringImplallows (isValidLength<char16_t>caps it a few characters belowString::MaxLength), soresolveRopethrows aRangeErrorwith the messageOut of memorywithout attempting an allocation. It is an ordinary catchable exception about one oversized value, not a process-level allocation failure, which is why failing the call (or the build) is the proportionate response. An 8-bit rope of the same length would instead try a real 2 GB allocation, hence the\u0100characters in the tests.RETURN_IF_EXCEPTION). On the Rust side this check isfrom_js_host_call_generic/TopExceptionScope, which turns a pending exception intoErr(JsError::Thrown). A host function that returns normally with an exception pending is an assertion failure in debug builds; in release JSC throws it into the calling JS frame after the host function returns, which is what produced the secondaddErroranswer here.Resolveand blocks on it until the JS thread answers exactly once by settingresolve.valueand callingon_resolve_async, which hands it back to the bundler thread.JSBundlerPlugin__addErroris the existing answer path for a callback that threw;plugin_msg_from_jsconverts the exception into thelogger.Msgthaton_resolvelogs against the importing file.Repro (entry.ts contains
import "huge";)external: truematters: with it the JS side never reads the string, so the first flatten happens inonResolveAsync.{ path: "/x", namespace: huge, external: true }behaves the same.Before, debug build:
Before, release canary da3851e, single run:
true []. The same build in aforloop of 40 iterations:(sometimes
panic: infallible: runtime export, depending on which answer the bundler thread sees first).After (debug and release):
false [ "Out of memory" ], reported atentry.ts, 40 out of 40 iterations, and a followingBun.buildin the same process succeeds.Other callers, before the change on the release canary,
ws.send(huge)delivered an empty text message to the client and then threw; after the change it throws without sending anything (websocket-server.test.tsasserts the client only receives the"done"sent afterwards).Bun.semver.order(huge, "1.0.0")aborted debug builds on the same assertion before and throws theRangeErrornow.