Skip to content

fix(runtime): never revive a JS block whose dispose has started - #500

Merged
edusperoni merged 4 commits into
mainfrom
fix/jsblock-cache-race
Oct 9, 2026
Merged

edusperoni merged 4 commits into
mainfrom
fix/jsblock-cache-race

Conversation

@edusperoni

@edusperoni edusperoni commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

A JS function passed as a block parameter is converted to a JSBlock once, and a BlockWrapper is cached on the function. On a cache hit, SetFFIParams did CFAutorelease(Block_copy(block)) with no liveness check.

A JSBlock's dispose runs on whichever thread drops the last native reference, and it takes the isolate's v8::Locker. libclosure's latching_incr_int (used by _Block_copy) does not check BLOCK_DEALLOCATING. That allows this race:

  1. Thread B drops the last reference. libclosure sets BLOCK_DEALLOCATING and calls dispose, which blocks on the Locker because thread A is running JS.
  2. Thread A passes the same JS function as a block again. On the cache hit, Block_copy "succeeds" on the dying block, and native code keeps it (for example NSOperationQueue.mainQueue.addOperationWithBlock, which Utils.dispatchToMainThread uses).
  3. Thread A releases the Locker. Dispose finishes, and libclosure frees the block while those references are still live.

Production signatures, from an Angular + zone.js app that runs JS callbacks on background queues and re-dispatches to the main queue during network-reconnect storms:

  • _Block_copy at 0x8 under -[NSBlockOperation main] on the main queue: the block's memory had been freed and zeroed, so flags == 0 sent it down the stack-block branch through a NULL descriptor.
  • objc_release in _dispatch_last_resort_autorelease_pool_pop on serial queues.

Fix

  • Cache hit takes a reference only while the block is live. TryRetainBlock CASes the refcount in the same flags word libclosure uses. It refuses when BLOCK_DEALLOCATING is set or the refcount is 0, and it respects the saturation latch. If it refuses, the call builds a fresh block and the slot is overwritten. Reading the cached block's flags is safe: while the isolate is valid, dispose clears the slot under the same Locker the cache-hit thread holds, and does so before libclosure frees the block.

  • Native blocks keep Block_copy. These are wrappers with OwnsBlock(), built in GetResult for blocks returned from native code. The JS function's own copy keeps them alive.

  • Dispose always frees its own BlockWrapper. It clears the slot only when the slot still holds that wrapper. Previously a wrapper superseded in the slot was skipped and leaked, and with the first fix that case now happens whenever a refused cache hit refills the slot.

  • ObjectManager::DisposeValue leaves JS-created block wrappers alone. These are the !OwnsBlock() wrappers, and the JSBlock owns them. Before, a function registered through interop.FunctionReference and then passed as a block had its block's wrapper freed by a worker's teardown (DisposeAllRegistered) while native code still held the block. The block's dispose then freed it again when native code released the block.

  • interop.handleof(fn) returns the cached block only while it is live. It try-retains the block and autoreleases it, so the pointer stays valid for the rest of the turn, like a block passed to a native call. If the block is dying or already gone, fn is treated like a function that was never marshalled, which throws "Unknown type". Before, it returned the cached pointer unretained, including for a block whose dispose had already started.

Tests

BlockCacheRaceTests.js, with fixtures in TNSTestNativeCallbacks:

  • keepBlock:releaseMode: keeps the block and drops it 0–2 ms later from one of three places: a concurrent queue, a serial queue, or after also enqueueing it on the main NSOperationQueue.
  • repeat:pausingAfter: re-marshals the same function 600 times. Each step runs in its own autorelease pool, followed by a 0–3 ms sleep that stays inside the JS turn, so the last release lands while this thread still holds the Locker.
  • Each release mode runs with JS on the main thread and with JS on a GCD worker.

More specs:

  • Dispose parked on the Locker, made deterministic. The block's last reference is dropped from a background queue while the JS turn sleeps. Two specs then check that handleof doesn't hand the dying block out, and that marshalling the function again (via addOperationWithBlock) gets a working block.
  • Worker teardown. A worker registers fn with interop.FunctionReference and gives native code a block built from it, which native code keeps past the worker's teardown.

Results under ASan (./run_tests.sh -a):

  • Before the cache-hit fix (commit 1): the app crashed twice in a row, once with JS on main and once with JS on the worker. Both reports show the expected interleaving: one thread holding the Locker inside repeat:pausingAfter:, and a release-queue thread in _Block_release → JSBlock dispose → v8::Locker::Locker. The fault itself is a wild pointer in the fixture's __block storage. The freed JSBlock chunk was reused for that storage, and libclosure's latching decrement on the dead block's flags (offset 8) corrupted the byref's forwarding pointer. libsystem_blocks is uninstrumented, so ASan reports a SEGV rather than a heap-use-after-free.
  • Before the DisposeValue fix (commit 3): ASan reported a heap-use-after-free in the JSBlock dispose helper, on the block's wrapper, which the worker's teardown had already freed.
  • With all fixes: 1753 specs, 0 failures, 16 skipped.

Known gap (pre-existing, follow-up PR)

  • Teardown window. During isolate teardown, ~Runtime drops the isolate from the live registry, which makes IsValid() false, without holding the Locker. A foreign thread already running worker JS keeps going until TerminateExecution unwinds it. In that window, a JSBlock dispose on a third thread skips the Locker and frees the wrapper, and libclosure then frees the block, while the still-running JS can reach both through the cache slot.
  • Dispose holds the Locker on a foreign thread. It can wait out a whole JS turn, and could deadlock if the Locker holder dispatch_syncs onto that queue. A deferred dispose was tried and reverted earlier (2ef5e50).

Summary by CodeRabbit

  • Bug Fixes
    • Improved reliability of JavaScript callbacks passed to native code, particularly during concurrent execution and block disposal.
    • Prevented stale block handles from being returned after disposal begins.
  • Tests
    • Added coverage for callback retention and release across threads, delayed disposal, and worker teardown.

Re-marshals one JS function as a block while native code drops the
cached block's last reference from a background queue. The releasing
thread starts the block's dispose, which waits for the isolate's Locker
held by the thread running JS; that thread's next cache hit Block_copies
the dying block, and libclosure frees it once dispose returns while
references taken in the meantime are still live.

Covers release from a concurrent queue, a serial queue, and via the main
NSOperationQueue, with JS on the main thread and on a GCD worker.
Crashes under ASan (write-after-free on the freed block's flags) without
the runtime fix.
A JS function passed as a block caches its block on itself, and a cache
hit Block_copy'd that block. libclosure's increment ignores
BLOCK_DEALLOCATING, so when another thread dropped the last reference
and its dispose was waiting for the isolate's Locker, the Locker holder
handed the dying block back to native code, which libclosure freed once
dispose returned. In production this showed up as _Block_copy faulting
at 0x8 under -[NSBlockOperation main] and as over-releases in dispatch's
autorelease pool pops.

The cache hit now takes a reference only while the block is live (a CAS
on the same flags word libclosure uses) and otherwise builds a fresh
block. Dispose always frees its own BlockWrapper and clears the slot only
when the slot still holds it, so a wrapper superseded by a newer block
is no longer leaked.
@coderabbitai

coderabbitai Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 871e9734-3284-44b4-9433-b05fd1f77cc5

📥 Commits

Reviewing files that changed from the base of the PR and between c036f33 and a9f6fb5.


📒 Files selected for processing (9)
  • NativeScript/runtime/Interop.h
  • NativeScript/runtime/Interop.mm
  • NativeScript/runtime/InteropTypes.mm
  • NativeScript/runtime/ObjectManager.mm
  • TestFixtures/TNSTestNativeCallbacks.h
  • TestFixtures/TNSTestNativeCallbacks.m
  • TestRunner/app/tests/BlockCacheRaceTests.js
  • TestRunner/app/tests/blockFunctionReferenceWorker.js
  • TestRunner/app/tests/index.js

Included review availability: This review used your included allowance. Your plan provides up to 2 included reviews per hour; 1 remain after this review.



📝 Walkthrough

Walkthrough

The runtime now checks cached JavaScript block liveness before retaining a block during marshalling or handle lookup. Block disposal and wrapper ownership handling have changed. Native callback helpers and tests cover asynchronous release, disposal, and worker teardown.

Changes

JavaScript block lifetime

Layer / File(s) Summary
Live block retention and disposal
NativeScript/runtime/Interop.h, NativeScript/runtime/Interop.mm
TryRetainJSBlock atomically retains a JavaScript block only when it is live. Disposal deletes the wrapper even when the callback slot holds another value.
Block conversion and ownership
NativeScript/runtime/Interop.mm, NativeScript/runtime/InteropTypes.mm, NativeScript/runtime/ObjectManager.mm
Block conversion and HandleOf return or retain non-owned blocks only when the live-retain check succeeds. DisposeValue returns without releasing blocks that the wrapper does not own.
Release helpers and lifetime tests
TestFixtures/TNSTestNativeCallbacks.h, TestFixtures/TNSTestNativeCallbacks.m, TestRunner/app/tests/BlockCacheRaceTests.js, TestRunner/app/tests/blockFunctionReferenceWorker.js, TestRunner/app/tests/index.js
Native helpers retain and release blocks asynchronously and run callbacks on selected queues. Tests cover release timing, handle lookup, remarshal behavior, and worker teardown.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Bug fix


Merge Risk: ⚪ Minimal · up to a9f6f

No actionable regression is established in this PR. It is mergeable after normal checks.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 6 functions across 5 files. (4 skipped: 4… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly and concisely describes the main runtime fix: preventing a JS block from being revived after disposal begins.
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.

Full details: Docstring Coverage

Explanation

Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 6 functions across 5 files. (4 skipped: 4 unsupported.)



  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR


  • 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

A rabbit checks each block’s refcount twice
Then waits for release, neat and precise
Across queues the callbacks run
The worker posts “kept” when done
And hops away beneath the moonlight nice

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

Parks a cached block's dispose on the isolate's Locker deterministically
(last reference dropped from a background queue while the JS turn sleeps)
and checks that interop.handleof does not hand it out and that a new
marshal of the function gets a working block.

A worker that registers a function through interop.FunctionReference and
gives native code a block built from it: the worker's teardown disposes
the function, and the block's own dispose runs after the isolate is gone.
Without the runtime fix both free the block's wrapper (ASan
heap-use-after-free in the JSBlock dispose helper).
…ly live blocks

ObjectManager::DisposeValue freed whatever wrapper sat in a disposed
object's slot. A function registered through interop.FunctionReference
and then passed as a block holds its JSBlock's wrapper there, so a
worker's teardown freed that wrapper while native code still held the
block, and the block's dispose freed it again once released. The
JSBlock owns that wrapper; DisposeValue now leaves it alone.

interop.handleof on a JS function returned its cached block unretained,
including one whose dispose had already started. It now returns the
block only while it is live, retained for the rest of the turn, and
otherwise treats the function like one that was never marshalled.

TryRetainJSBlock becomes an Interop member so HandleOf can share it.
@edusperoni
edusperoni marked this pull request as ready for review October 9, 2026 17:28
@edusperoni
edusperoni added this pull request to stack #502 October 9, 2026 17:29
@edusperoni
edusperoni merged commit 249ec1a into main Oct 9, 2026
10 checks passed
@edusperoni
edusperoni deleted the fix/jsblock-cache-race branch October 9, 2026 19:13
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