Skip to content

perry-jsruntime: RefCell re-entrancy panic when JS-callback reads property from JS object arg #255

@proggeramlug

Description

@proggeramlug

Summary

When a Perry closure passed to a JS-imported function (via JsCreateCallback, landed in #248 Phase 2B / PR #254) reads a property from a JS object passed in as a callback argument, perry-jsruntime panics with RefCell already borrowed. The closure-marshaling itself works correctly; the issue is that V8's trampoline holds the JsRuntimeState borrow while invoking the Perry callback, and the callback's js_get_property (or any other with_runtime(...) site) re-enters on the same borrow.

Repro

// test_re-entrancy.ts
import { pipeline } from "@codehz/pipeline";
const gp = pipeline();
gp.use((ctx: any) => {
  console.log("inside callback, deltaTime =", ctx.deltaTime);
});
gp.run({ deltaTime: 16 });
console.log("done");
perry compile test_re-entrancy.ts -o /tmp/re && /tmp/re

Output:

thread '<unnamed>' (...) panicked at crates/perry-jsruntime/src/lib.rs:123:28:
RefCell already borrowed
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

thread '<unnamed>' (...) panicked at /rustc/.../library/core/src/panicking.rs:225:5:
panic in a function that cannot unwind

Exit code is 0 because the abort happens inside an extern "C" boundary that can't unwind.

Root cause

crates/perry-jsruntime/src/lib.rs:123 is inside with_runtime which does RUNTIME.with(|cell| cell.borrow_mut()). The call chain:

  1. User code calls gp.run({deltaTime: 16})js_call_method → enters with_runtime (borrow Support custom menu bar items #1).
  2. V8 dispatches into JS pipeline's run() method → calls the registered Perry callback.
  3. V8's native_callback_trampoline (crates/perry-jsruntime/src/interop.rs:993) calls js_closure_call_array(closure_env, args_ptr, args_len).
  4. Inside the Perry closure body, ctx.deltaTime lowers to js_get_property(ctx, "deltaTime", ...)with_runtime again → tries to borrow_mut the already-borrowed RefCell → panic.

Workarounds

  • Call the callback's argument-marshaling at the bridge layer instead of inside the Perry closure body. This means the trampoline itself converts each arg's JS-handle properties into plain values BEFORE invoking the callback, but is impractical without arity/property knowledge ahead of time.
  • Don't read JS-side properties inside callbacks. Pass primitives only (gp.run(16) instead of gp.run({deltaTime: 16})). Closes the immediate symptom but defeats the point of JS interop for any non-trivial use case.
  • Use a re-entrant runtime state — likely the right fix, but requires careful scope analysis of every with_runtime site.

Possible fix directions

  • Replace RefCell<JsRuntimeState> with RefCell<Option<JsRuntimeState>> + a take/restore pattern: each with_runtime body extracts the state, drops the RefCell borrow, runs the closure with &mut state, then puts it back. Re-entrant calls can take the state out again. Risky if a callback's exception unwinds past the put-back.
  • Or: switch the property-read FFI (and friends) to a non-with_runtime shape that only needs V8 handles, not the full JsRuntimeState. Probably the cleanest path but requires a per-FFI audit.
  • Or: have V8's trampoline drop its borrow before invoking the Perry callback (the borrow only protects the V8 init/setup, not the actual JS execution). Requires changes in native_callback_trampoline plus discipline that the trampoline doesn't touch state after the callback returns.

Surfaced by

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions