Skip to content

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 into
PerryTS:mainfrom
proggeramlug:fix/1824-mysql2-worker-thread-jsvalue
May 27, 2026
Merged

proggeramlug merged 1 commit into
PerryTS:mainfrom
proggeramlug:fix/1824-mysql2-worker-thread-jsvalue

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

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:

spawn_blocking(move || {
    let outcome = /* run SQL */;
    match outcome {
        Ok(out) => promise.resolve(outcome_to_jsvalue(&out)), // <-- on the worker thread
        ...
    }
});

outcome_to_jsvalue → raws_to_result_tuple allocates the result with js_array_alloc / js_array_push / raw_row_to_js_object (objects + column strings). But perry-ffi's own spawn_blocking docs are explicit:

the closure runs on a blocking-pool thread, so it must NOT touch perry-runtime's thread-local arena directly … constructing JSValues by hand on the blocking thread will trigger UB.

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 in js_object_get_field_by_name reading 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:

  • perry-ffi: JsPromise::resolve_with<F: FnOnce() -> JsValue + Send>(f) — boxes the JS-builder closure and hands it across perry_ffi_promise_resolve_deferred. The closure runs once, on the main thread, at resolution time.
  • perry-stdlib: implement perry_ffi_promise_resolve_deferred on top of the existing queue_deferred_resolution / js_stdlib_process_pending pump (the converter already runs on the main thread there).
  • perry-ext-mysql2: switch the three row-returning queries from promise.resolve(outcome_to_jsvalue(&out)) to promise.resolve_with(move || outcome_to_jsvalue(&out)). The worker now produces only the Send Rust QueryOutcome; the JSValue is built on the main thread.

Verification

  • cargo check passes for perry-ffi, perry-stdlib, perry-ext-mysql2.
  • Built into a deployed Fastify + @perryts/mysql service; its mysql-backed endpoints return correct row data (37-row result verified) with the change in place.

Notes

  • perry-ext-pg has the identical spawn_blocking + outcome_to_jsvalue pattern and should adopt resolve_with too — left as a follow-up to keep this PR focused/verified.
  • Found while debugging a separate, still-open crash in the same service (a long-running background job segfaults after the second batch of work). That one is a distinct async-frame issue under investigation and is not addressed here; this PR fixes the clearly-incorrect worker-thread JSValue construction, which is a real latent corruption bug on its own.

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.
@proggeramlug
proggeramlug merged commit 5b5c2fc into PerryTS:main May 27, 2026
10 checks passed
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