Skip to content

fix(runtime): keep a JS function's block apart from its own wrapper - #504

Draft
edusperoni wants to merge 2 commits into
mainfrom
fix/function-reference-block-slot
Draft

edusperoni wants to merge 2 commits into
mainfrom
fix/function-reference-block-slot

Conversation

@edusperoni

Copy link
Copy Markdown
Collaborator

Cause

A JS function marshalled as a block cached its BlockWrapper in the per-object slot tns::SetValue/tns::GetValue uses, and that is the same slot interop.FunctionReference keeps its FunctionReferenceWrapper in. Each use evicted the other:

  • FunctionReference, then block: the FunctionReferenceWrapper leaked, along with any trampoline it had cached. A later function pointer marshal found a BlockWrapper and hit tns::Assert(false) in Interop::WriteValue.
  • Block, then FunctionReference: the block cache stopped hitting, and every block marshal after that built a new block.

Fix

  • The JS block cache now lives in its own private slot on the function (tns::{Set,Get,Delete}JSBlockWrapper, private key jsBlock). The function's own wrapper slot is left alone.
  • The ownership rule from fix(runtime): never revive a JS block whose dispose has started #500/fix(runtime): free a JS block's wrapper only once its isolate's teardown is done #501 is unchanged. Interop::JSBlock owns its BlockWrapper and always deletes it in dispose. It clears the cache slot only while that slot still holds its own wrapper, and still under the isolate pin and Locker. A JS block's wrapper never sits in the slot ObjectManager::DisposeValue reads, so teardown and GC never touch it. Cache hits still go through TryRetainJSBlock.
  • interop.handleof(fn) returns a FunctionReference's trampoline once one exists. Otherwise it returns the live cached block, and otherwise it throws as before. interop.sizeof of a function with a cached block still reports pointer size.
  • A second new interop.FunctionReference(fn) on the same function keeps the existing wrapper and trampoline instead of leaking them.
  • A function pointer argument that is not a Pointer, a native function pointer or a FunctionReference now throws a JS error instead of asserting.

Tests

FunctionReferenceBlockTests.js (new, committed first; on main it aborts on the assert) covers:

  • FunctionReference, then block, then function pointer;
  • function pointer, then block, then function pointer;
  • block, then FunctionReference, then block, with interop.handleof returning the same block throughout;
  • handleof precedence once a trampoline exists;
  • a repeated interop.FunctionReference;
  • the thrown error for a plain function;
  • collection of functions that were both kinds;
  • a worker torn down while native code still holds the block of a FunctionReference that has a trampoline.

The existing "JS block outliving its worker" specs still test what they describe: a block outliving, or released by, its worker's teardown while the function is FunctionReference-registered. Teardown now disposes the function's FunctionReferenceWrapper, and the block's own dispose frees the block wrapper. blockTeardownReleaseWorker.js's comment is updated to match.

Suite (iOS 26.3.1 simulator):

  • plain: 1762 specs, 0 failures, 11 skipped
  • ASan: 1762 specs, 0 failures, 16 skipped

Fixes #503

A function wrapped in interop.FunctionReference and then marshalled as
a block loses its FunctionReference state, so passing it as a C
function pointer afterwards asserts. Marshalling it as a block after
interop.FunctionReference also stops reusing the block it cached.

Cover both orders, interop.handleof across them, a function pointer
argument that is not a FunctionReference, collection of such
functions, and a worker torn down while native code holds the block.
A JS function marshalled as a block cached its BlockWrapper in the same
slot that interop.FunctionReference keeps its wrapper in, so each use
evicted the other: the FunctionReferenceWrapper leaked and a later
function pointer marshal asserted, or the block cache stopped hitting.

The block cache now lives in its own private slot on the function. The
JSBlock still owns its BlockWrapper and only clears that slot while it
still holds that wrapper, so ObjectManager never sees a JS block's
wrapper. interop.handleof reports a FunctionReference's trampoline once
it has one, and the live block otherwise. A second
interop.FunctionReference on the same function keeps the existing
wrapper and trampoline.

A function pointer argument that is not a pointer, native function
pointer or FunctionReference throws instead of asserting.

Fixes #503
@coderabbitai

coderabbitai Bot commented Oct 9, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

This branch has not been deployed

No deployments
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.

interop.FunctionReference function passed as a block, then as a function pointer, asserts

1 participant