fix(runtime): drain autoreleased objects on worker threads per callout - #451
Draft
edusperoni wants to merge 1 commit into
Draft
fix(runtime): drain autoreleased objects on worker threads per callout#451edusperoni wants to merge 1 commit into
edusperoni wants to merge 1 commit into
Conversation
Worker threads run a bare CFRunLoopRun with no autorelease pool management, so autoreleased ObjC objects created while executing JS on a worker (marshalled returns, framework temporaries, call-scoped block copies) accumulated in the thread's bottom pool and only drained when the worker died - a leak proportional to worker lifetime and native-call volume. The main thread does not have this problem because UIKit drains a pool once per run-loop pass. Scope pools to the runtime's own callouts instead of run-loop passes: EventLoop::RunGuarded wraps each non-bare work unit (both scheduler lanes, where all worker JS executes; bare entries may @throw on purpose and stay unwrapped), the worker's message-drain source wraps DrainPendingTasks, and explicit pools cover the boot phase (runs before the loop) and teardown (otherwise drains only when the backing NSOperation ends). On the main thread this only tightens drain latency; the lifetime contract - alive at least to the end of the current callout - is unchanged. A UIKit-style run-loop observer (tried in both single-pool and pool-per-nesting-level forms) aborts with AutoreleasePoolPage::badPop under the HTTP-ESM loader tests: nested CFRunLoopRunInMode pumps interleave foreign pool lifetimes across callouts, cutting observer-owned tokens. A pool pushed and popped inside a single callout cannot be interleaved with, and matches the codebase's existing entry-point pattern. The new spec autoreleases a TNSAllocLog on the worker in one timer callout and reports TNSGetOutput() from the next; the dealloc entry can only be present in between if the pool drained per callout.
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueThanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Worker threads run a bare
CFRunLoopRun()with no autorelease pool management. Autoreleased ObjC objects created while executing JS on a worker (marshalled method returns, framework-internal temporaries, call-scoped block copies) accumulate in the thread's implicit bottom pool and only drain when the worker dies — the backingNSOperation's pool is the only drain point. That is a memory leak proportional to worker lifetime and native-call volume. The main thread does not have this problem because UIKit installs run-loop observers that drain a pool once per loop pass.Fix
Scope pools to the runtime's own callouts instead of to run-loop passes:
EventLoop'sRunGuardedwraps each non-bare work unit in@autoreleasepool— this covers both scheduler lanes (timers/setTimeout, posted internal work, V8 platform tasks), which is where worker JS executes. Bare entries stay unwrapped: they may@throwon purpose, and an ObjC unwind must not cross a pool this code owns. On the main thread this only tightens drain latency (callout-end instead of UIKit's pass-end); the lifetime contract — an autoreleased object lives at least to the end of the current callout — is unchanged on both threads.DrainPendingTasks()in@autoreleasepool.@autoreleasepoolaround the worker boot phase (isolate creation + entry-script evaluation + first message drain), which runs beforeCFRunLoopRun().@autoreleasepoolaround worker teardown (DestroyInspector()+delete runtime), so teardown garbage dies deterministically instead of whenever the operation's pool drains.Why not a UIKit-style run-loop observer?
Two observer designs (single pool with drain-per-pass; one pool per run-loop nesting level, UIKit's scheme) were tried first and both abort with
AutoreleasePoolPage::badPopunder the HTTP-ESM loader tests: worker module loading pumps nestedCFRunLoopRunInModefrom inside callouts whose async machinery interleaves its own pool lifetimes across callouts, so an observer-owned token can be cut out from under the observer by a pool it does not control. A pool that is pushed and popped strictly inside a single callout cannot be interleaved with by construction — and per-callout@autoreleasepoolat entry points is already this codebase's established pattern (ModuleInternalCallbacks,InteropTypes,AnimationFrame,HttpLoader).Known limitation: callouts the runtime does not own (e.g. a notification block delivered directly to the worker loop) still autorelease into the bottom pool. All JS execution and marshalling paths are covered.
Test
TestRunner/app/tests/WorkerAutoreleasePoolTests.js+autoreleasePoolDrainWorker.js: the worker autoreleases aTNSAllocLoginstance (new+autoreleaseInstancefixture method —CFAutorelease(CFBridgingRetain(...)), so the pool holds the only reference and no JS wrapper retain is involved) inside one timer callout and reportsTNSGetOutput()from the next. The dealloc log can only be present in between if the pool drained between the callouts; without the fix it appears only at worker death, after the report is sent.Full suite: green (Debug, simulator).