Skip to content

fix(runtime): drain autoreleased objects on worker threads per callout - #451

Draft
edusperoni wants to merge 1 commit into
mainfrom
fix/worker-autorelease-pools
Draft

fix(runtime): drain autoreleased objects on worker threads per callout#451
edusperoni wants to merge 1 commit into
mainfrom
fix/worker-autorelease-pools

Conversation

@edusperoni

Copy link
Copy Markdown
Collaborator

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 backing NSOperation'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's RunGuarded wraps 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 @throw on 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.
  • The worker's message-drain source callout wraps DrainPendingTasks() in @autoreleasepool.
  • An explicit @autoreleasepool around the worker boot phase (isolate creation + entry-script evaluation + first message drain), which runs before CFRunLoopRun().
  • An explicit @autoreleasepool around 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::badPop under the HTTP-ESM loader tests: worker module loading pumps nested CFRunLoopRunInMode from 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 @autoreleasepool at 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 a TNSAllocLog instance (new +autoreleaseInstance fixture method — CFAutorelease(CFBridgingRetain(...)), so the pool holds the only reference and no JS wrapper retain is involved) inside one timer callout and reports TNSGetOutput() 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).

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.
@coderabbitai

coderabbitai Bot commented Aug 25, 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

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.

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