Repository navigation
fix(runtime): free a JS block's wrapper only once its isolate's teardown is done - #501
Conversation
A worker leaves an NSBlockOperation as the only owner of a block built from a function that interop.FunctionReference registered first. The worker's ~Runtime has already dropped the isolate from the live registry when DisposeAllRegistered releases the operation, so the block's dispose takes the invalid-isolate branch and frees the BlockWrapper without the Locker, while the function's slot still points at it. The walk then reaches the function and reads the freed wrapper (heap-use-after-free in BaseDataWrapper::IsGcProtected under ASan).
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configuration
📒 Files selected for processing (7)
Included review availability: This review used your included allowance. Your plan provides up to 2 included reviews per hour; 0 remain after this review. 📝 WalkthroughWalkthroughThe runtime adds a shared isolate gate and scoped pins. Callback paths acquire pins before accessing the isolate. Teardown closes the gate and defers disposal while pins remain. A worker teardown test covers block release while the worker still holds its function for disposal. ChangesIsolate teardown coordination
Priority: ➖ Normal Estimated code review effort: 3 (Moderate) | ~25 minutes Change: Bug fix Sequence Diagram(s)sequenceDiagram
participant MethodCallback
participant IsolateWrapper
participant IsolateGate
participant Runtime
participant DisposeIsolateWhenPossible
MethodCallback->>IsolateWrapper: Pin()
IsolateWrapper->>IsolateGate: TryPin()
Runtime->>IsolateGate: Close() after teardown
Runtime->>DisposeIsolateWhenPossible: pass gate for deferred disposal
DisposeIsolateWhenPossible->>IsolateGate: check active pins
Suggested reviewers: Merge Risk: ⚪ Minimal · up to No actionable issue remains from this review; the PR is mergeable after normal checks. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)✅ Passed checks (4 passed)Full details: Docstring CoverageExplanation Docstring coverage is 38.46% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 13 functions across 4 files. (3 skipped: 3 unsupported.)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
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. A rabbit guards the gate with care Comment |
d52bce3 to
d1326f6
Compare
|
@CodeRabbit review |
|
…own is done The JSBlock dispose helper skipped the Locker and freed the BlockWrapper as soon as IsolateWrapper::IsValid() turned false, but ~Runtime makes it false before taking the Locker. The function's slot still pointed at the wrapper, and both ~Runtime's own DisposeAllRegistered walk and JS already running on another thread under the Locker could read it after the free. A process-wide gate registry, keyed by a per-isolate id that survives InvalidateIsolate and is never reused, records for each isolate a pin count and a closed flag behind an os_unfair_lock (UnfairMutex). Runtime::Init opens the entry right after creating the isolate's Caches; ~Runtime closes it as the last step of its locked section, after every walk that reads wrappers through JS objects, and keeps its existing ordering otherwise. DisposeIsolateWhenPossible defers, through its existing 10 ms re-post, while the entry is pinned, and retires it before Isolate::Dispose, so a pinned thread never waits on or holds the Locker of a disposed isolate. IsolateWrapper keeps only the isolate and that id, staying trivially copyable for the ObjC blocks that capture it by value. The dispose helper pins, takes the Locker and clears the slot unless the gate closed meanwhile; only a refused pin lets it free without the Locker. ArgConverter::MethodCallback pins across its Locker too, keeping its IsValid checks, which closes the window where a callback waiting on the Locker could outlive Isolate::Dispose. Both skip the pin on a thread that has already entered the isolate: an entered isolate stays in use, which already defers its disposal.
d1326f6 to
0e40c4d
Compare
Stacked on #500. Draft.
Bug
A JS function passed as a block param becomes a malloc'd
Interop::JSBlock, and aBlockWrapperowned by that block is cached in the function's slot. When the last native reference goes, the block's dispose helper frees that wrapper.The helper used to skip the Locker and free the wrapper as soon as
IsolateWrapper::IsValid()was false.~RuntimemakesIsValid()false at its very start: it erases the isolate fromisolates_and callsInvalidateIsolate()without the Locker, and only takes the Locker later. Until its locked section is done, the function's slot can still be read, and it would point at the freed wrapper. Two readers can get to it:~Runtimeitself.DisposeAllRegisteredcallsGetValueon every registered object. A function registered throughinterop.FunctionReferenceis visited after an earlier entry has released the block's last owner. The new test reproduces this.GetValuereturns the freed wrapper, andTryRetainJSBlockreads the freed block.DestroyInspectortakes the Locker first.shutdownRuntimecalled from outside JS.Repro
Test:
BlockCacheRaceTests.js→ "JS block outliving its worker / is released by the teardown that later disposes its function". The worker script isblockTeardownReleaseWorker.js.The worker:
fnthroughinterop.FunctionReference;NSBlockOperationthe only owner of the block built fromfn;The registration walk goes newest first, so it releases the operation before it reaches
fn.Without the fix:
BaseDataWrapper::IsGcProtected.DisposeValue←DisposeAllRegistered←~Runtime._Block_release←-[NSBlockOperation dealloc]←DisposeAllRegistered, on the same thread.EXC_BAD_ACCESS(KERN_INVALID_ADDRESS at 0xbeadde7d0340) at the virtualwrapper->Type()call inDisposeValue.Fix: per-isolate lifetime gate
The gate is a process-wide registry in
IsolateWrapper.cpp(IsolateGates::), guarded by anUnfairMutex(os_unfair_lock). The table is a never-destroyedrobin_hood::unordered_map, so it is still usable whileexit()runs static destructors.Caches::Init.Caches::getGateId()returns it; unlikegetIsolateId()it survivesInvalidateIsolate, and ids are never reused.{pins, closed}. A missing entry reads as closed.Runtime::Init, right after the isolate's Caches is created, so noIsolateWrapperfor the isolate can exist before its entry.IsolateWrapperkeeps only{Isolate*, int}, and astatic_assertkeeps it trivially copyable. ItsPin()andIsTornDown()resolve through the registry.IsolatePinis the scoped (RAII) pin and holds only the id. Declare it before the Locker, so the Locker is released first.Why a registry and not a refcounted token: the extended classes' synthesized
+initialize/-retain/-releaseObjC blocks captureIsolateWrapperby value and live as long as the process. Ashared_ptrinsideIsolateWrapperwould keep each gate alive forever.~RuntimeDisposeAllRegistered,SweepAll,CloseAllPortsandCaches::Remove.DisposeIsolateWhenPossible(isolate, gateId)now defers while the isolate is in use or its entry is pinned, using its existing 10 ms re-post. BeforeIsolate::Disposeit removes the entry, which is closed with zero pins at that point, so no new pin can arrive.shutdownRuntimefrom JS, can't deadlock it. The deferred block captures only the int id.JSBlock dispose
IsValid():IsValid()is already false while the slot is still reachable.If the pin is refused, the gate is closed and nothing can reach the slot any more, so the helper frees without the Locker.
ArgConverter::MethodCallbackIsValid()checks: before the Locker, and again under it, so it still bails once the runtime is invalid.Isolate::Dispose.Fast path: a thread that has already entered the isolate takes no pin. This covers the dispose running inline during
DisposeAllRegisteredand nested callbacks. An entered isolate stays in use, which already defersDisposeIsolateWhenPossible.Deadlock exposure: between the
isolates_erase andClose, a block dispose on another thread now waits for the Locker instead of skipping it. That widens an existing hazard: a-deallocreached fromDisposeAllRegisteredthatdispatch_syncs to a thread waiting on this isolate's Locker would deadlock. This deadlock class already exists while the isolate is valid.Cache-hit path: unchanged and costs nothing new; it already runs under the Locker.
exit(): never runs~Runtime, so nothing changes for a dying process.Not converted
These other foreign-thread
IsValid→Lockertakers keep the old check-then-lock and its narrowIsolate::Disposewindow. They are left for a follow-up decision:ArrayAdapter,DictionaryAdapterandNSDataAdaptermethods and deallocs;ClassBuilder.mm;AnimationFrame;Timers.hpp.Messaging.cppnever takes the receiving isolate's Locker.Results
./run_tests.sh -a)./run_tests.sh)The repro test passes in both runs.