fix: keep the Worker object rooted until its thread ends, and report the end as nsworkerended - #2044
Draft
edusperoni wants to merge 1 commit into
Draft
fix: keep the Worker object rooted until its thread ends, and report the end as nsworkerended#2044edusperoni wants to merge 1 commit into
edusperoni wants to merge 1 commit into
Conversation
…the end as nsworkerended terminate() reset the Worker object's persistent and dropped the registry entry the moment it was called, while the thread was still winding down: the wrapper stopped being reachable from native before it had finished, and anything the worker had already queued on the parent's loop was discarded on arrival. The root now survives terminate(); it is released by the worker thread's own last act, which posts the end back to the parent's event loop. That post no longer only clears. On the parent's thread it dispatches the internal `nsworkerended` event on the Worker object and only then releases the persistent and the registry entry, so the end of a worker is observable from JS for the first time. The node:worker_threads shim listens for it, which is what lets 'exit' be emitted exactly once for a worker's own close() as much as for terminate(), and lets terminate() resolve at that point rather than off a microtask — after every message and error the worker had already sent. A parent that is itself tearing down clears its children directly and never delivers the notification, matching iOS. Android needed neither half of the iOS change's lifetime rework: the wrapper is shared_ptr-owned by the registry and by the detached thread itself, its poWorker_ has been a strong Persistent since construction, and the Worker object is a plain FunctionTemplate instance ObjectManager never sees — so there was no finalizer resurrection to take it off, and worker-thread posts already reached the parent through a weak_ptr to its event loop rather than through its isolate. Mirrors NativeScript/ios#456.
|
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 |
edusperoni
added this pull request to stack #2046
September 11, 2026 23:27
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.
Stacked on #2043 (
feat/worker-threads) — merge that first.What this fixes
terminate()reset the Worker object'sPersistentand dropped the registry entry synchronously, while the worker thread was still winding down. From that moment the wrapper was unreachable from native, the end of the worker was unobservable from JS, and any message the worker had already queued on the parent's loop was discarded when it arrived.Android vs iOS lifetime — what was verified before porting
The iOS change is largely about replacing finalizer resurrection with reachability. Android never had that problem, so most of it does not apply:
WorkerWrapper::poWorker_isnew Persistent<Object>(parentIsolate, workerObject)(WorkerWrapper.cpp) and is never made weak. There is noRootWorkerObject/UnrootWorkerObjectto add.CallbackHandlers::NewThreadCallbacktakesargs.This()of the plainFunctionTemplateinstalled inRuntime::PrepareV8Runtime(Runtime.cpp, theWorkerconstructor block) and hands it to the wrapper; nothing registers it withObjectManageror marks it for GC. So none of iOS'sDataWrapper/ObjectManagerrefuse-and-re-weaken work has an Android counterpart.shared_ptr-owned by the registry and by the detached thread (Start()capturesshared_from_this()andBackgroundLooperholds it for the thread's whole life), sothiscannot die under the thread. iOS's "read everything before publishingisDisposed_" hardening exists because a tearing-down iOS parent can delete the wrapper concurrently; that cannot happen here, and it is left alone.parentTasks_is astd::weak_ptr<EventLoop>captured on the parent's thread at construction, andPostMessageToParent,PassUncaughtExceptionFromWorkerToParentand the thread-exit post all go through it. The only reads of the parent isolate's runtime slot (Runtime::GetRuntime(parentIsolate)) happen inside lambdas that run on the parent's thread. Nothing was wrong; nothing changed.The change
CallbackHandlers::WorkerObjectTerminateCallbackno longer callsWorkerWrapper::ClearWorkerOnParent(id).terminate()only starts the wind-down.WorkerWrapper::BackgroundLooper's final post to the parent's loop now runs a newWorkerWrapper::NotifyThreadEndedOnParent(workerId): on the parent's thread, with the parent isolate locked and entered, it resolves the wrapper by id, takes the Worker object out ofpoWorker_, callsWorkerEvents::EmitEndedunder aTryCatch(a throwing listener is reported exactly asFireErrorOnParentWorkerObject/FireMessageOnParentWorkerObjectreport one —ContainUncaughtCallbackExceptionthenReportFromEventLoopEntry), and only then callsClearWorkerOnParent.WorkerEvents::EmitEndedmirrorsEmitError's shape: a thirdemitEndedcallout cached inWorkerEventsStateatWorkerEvents::Init.js/worker-events.jsgainsemitEnded(), which dispatches a plainEvent("nsworkerended")on the Worker object.js/node-worker-threads.js: the shim'sWorkerlistens fornsworkerendedand reports the exit from there.exit(code0) is emitted exactly once, for a self-close()as much as forterminate(), andterminate()now resolves from the same notification instead of off a microtask, so nothing the worker sent can arrive afterexit.TerminateChildren→ClearWorkerOnParenton the parent's thread) or whose loop is gone (expiredparentTasks_) never delivers the notification. That is deliberate, matches iOS, and is documented as best effort.Consumer audit for the removed clear. Nothing relied on the persistent being empty after
terminate()for correctness:PostMessageToParentalready returns early onisTerminating_; every error source is gated at the point of forwarding (CallWorkerScopeOnErrorHandlereturns early onIsTerminating(), the unhandled-rejection path inNativeScriptException.cppchecksIsTerminating()/IsDisposed(), andBackgroundLooper's catch checks!isTerminating_), so no error from a terminated worker reaches the parent; theisTerminatedprivate only guards a doubleterminate();FireMessageOnParentWorkerObject/FireErrorOnParentWorkerObjectkeep their empty-persistent guards for the teardown paths that still clear early. The one observable change is the intended one: a message the worker queued beforeterminate()is now delivered instead of being dropped on arrival, and it is delivered beforensworkerended.Tests
New Android-only specs in
test-app/app/src/main/assets/app/tests/testWorkerLifetime.js(wired inmainpage.js), withworkerLifetimeCloseWorker.jsandmessaging/deadlockChild.js/messaging/deadlockParent.js:WeakMapkey (the ephemeron repro, kept as a regression guard; collections are driven from a task via__collect({ execution: "async" })so conservative stack scanning does not keep the workers alive)nsworkerended, since the root outlivesterminate()by designnode:worker_threads:exitexactly once on self-close();exitexactly once onterminate(), after the thread ended, withterminate()resolving after it and a secondterminate()resolving immediatelyFull device suite on a Pixel_3a_API_36 arm64 emulator: 1398 specs, 0 failures, 4 skipped (baseline on
feat/worker-threadswas 1391/0/4; the 7 new specs all ran and passed).npm run lintclean.Deviations from iOS (ab72efc2)
DataWrapper.h,ObjectManager.mm,RootWorkerObject/UnrootWorkerObject,EndWrapperLifetime,selfRef_. Android's persistent is strong from construction and the Worker object is not an ObjectManager object, so there is no weak handle to clear and no resurrection branch to take it off. The wrapper is kept alive byshared_ptrs, so iOS's atomic liveness token is unnecessary — the notification resolves the wrapper through the id-keyed registry instead.PostToRuntimeLoop→PostToLoop/mainLoop_. Android already capturesparentTasks_as aweak_ptr<EventLoop>on the parent's thread; the isolate-slot read iOS was removing does not exist here.isDisposed_publication reordering. The thread owns ashared_ptrto the wrapper, so nothing can delete it mid-teardown.EmitEndedlives inWorkerEvents, notWorker. Android splits the worker-events callouts intoWorkerEvents.{h,cpp}; that is whereEmitMessage/EmitErroralready are.docs/knowledge/v8-resurrecting-finalizers.mdhas no Android counterpart (docs/knowledge/holds onlyv8-14-migration.md), so that hunk is skipped. Thejs/README.mdlistener-bag rule is reworded to the same effect without referring to a patch document this repo does not carry.Start().var/function, jasminedone) and bumpjasmine.DEFAULT_TIMEOUT_INTERVALto 30s the way the other worker suites do; the fixed waits in thenode:worker_threadsspecs are 2000 ms rather than iOS's 800 ms for emulator slowness. The two collectability specs additionally gate onnsworkerendedbefore observing theWeakRef, which iOS approximates with a fixed delay.Mirrors NativeScript/ios#456.