Repository navigation
fix(mysql2): build query result on the main thread, not the tokio blocking worker (worker-thread JSValue construction is UB) - #1957
Merged
proggeramlug merged 1 commit intoMay 27, 2026
Conversation
perry-ext-mysql2's row-returning queries call outcome_to_jsvalue() — which allocates arrays/objects/strings (js_array_alloc / js_array_push / raw_row_to_js_object) — *inside* spawn_blocking, i.e. on a tokio blocking-pool thread. perry-ffi's spawn_blocking docs are explicit that constructing JSValues off the main thread is UB: perry-runtime's heap allocator is thread-local, so the result set lands in a worker arena that is freed when the pooled thread idles out, leaving the main thread with dangling row objects — a use-after-free that surfaces under sustained query load (js_object_get_field_by_name on a freed object). Add JsPromise::resolve_with(FnOnce() -> JsValue + Send), which defers the JS construction to the main thread via the existing deferred-resolution pump (perry_ffi_promise_resolve_deferred -> queue_deferred_resolution -> js_stdlib_process_pending). The worker now produces only the Send Rust QueryOutcome; the JSValue is built on the main thread at resolution time. Switch mysql2's three row-returning query paths to resolve_with. perry-ext-pg has the same spawn_blocking + outcome_to_jsvalue pattern and should adopt resolve_with as a follow-up.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
perry-ext-mysql2's row-returning queries build the JS result on a tokio blocking-pool thread. In each of the three row-returning query paths the worker does:outcome_to_jsvalue→raws_to_result_tupleallocates the result withjs_array_alloc/js_array_push/raw_row_to_js_object(objects + column strings). But perry-ffi's ownspawn_blockingdocs are explicit:perry-runtime's heap allocator is
thread_local!, so the row objects land in the worker thread's arena. They're reachable from the main thread only until that pooled thread idles out (tokio's default ~10s), at which point the arena is dropped and the objects dangle. Under sustained query load this is a use-after-free — the main thread later faults injs_object_get_field_by_namereading a freed row object. Low-volume callers (a few fast queries per request) don't notice because the pooled thread stays alive across the request; a long-running job doing hundreds of queries does.Fix
Add a main-thread deferral path so bindings can return complex values safely:
JsPromise::resolve_with<F: FnOnce() -> JsValue + Send>(f)— boxes the JS-builder closure and hands it acrossperry_ffi_promise_resolve_deferred. The closure runs once, on the main thread, at resolution time.perry_ffi_promise_resolve_deferredon top of the existingqueue_deferred_resolution/js_stdlib_process_pendingpump (the converter already runs on the main thread there).promise.resolve(outcome_to_jsvalue(&out))topromise.resolve_with(move || outcome_to_jsvalue(&out)). The worker now produces only theSendRustQueryOutcome; the JSValue is built on the main thread.Verification
cargo checkpasses forperry-ffi,perry-stdlib,perry-ext-mysql2.@perryts/mysqlservice; its mysql-backed endpoints return correct row data (37-row result verified) with the change in place.Notes
perry-ext-pghas the identicalspawn_blocking+outcome_to_jsvaluepattern and should adoptresolve_withtoo — left as a follow-up to keep this PR focused/verified.