Repository navigation
fix: keep the Worker object rooted until its thread ends, and report the end as nsworkerended - #2044
edusperoni wants to merge 1 commit into
Conversation
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configuration
📒 Files selected for processing (15)
Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 1 remain after this review. 📝 WalkthroughWalkthroughThe runtime now retains worker objects until their threads finish and reports completion through an internal event. The Worker API uses that event to emit one zero-code ChangesWorker lifecycle
Priority: ➖ Normal Estimated code review effort: 3 (Moderate) | ~20 minutes Change: Bug fix Sequence Diagram(s)sequenceDiagram
participant WorkerThread
participant WorkerWrapper
participant WorkerEvents
participant Worker
participant NodeWorkerThreads
WorkerThread->>WorkerWrapper: Post NotifyThreadEndedOnParent
WorkerWrapper->>WorkerEvents: EmitEnded(worker)
WorkerEvents->>Worker: Dispatch nsworkerended
Worker->>NodeWorkerThreads: Handle nsworkerended
NodeWorkerThreads->>NodeWorkerThreads: Emit exit(0) and resolve waiters
WorkerWrapper->>WorkerWrapper: Clear worker registry entry and persistent handle
Merge Risk: ⚪ Minimal · up to The change keeps Worker objects alive until their threads end and reports exit and terminate() results at that point. No merge-blocking issue was identified from the supplied review material. Security Architecture ReviewSecurity architecture risk: 🔵 Low · up to The lifecycle change improves Worker retention and completion ordering without an identified expansion of worker authority. One failure-containment issue remains: a throwing exit listener can leave earlier termination promises permanently pending, even though native cleanup proceeds. Retained concerns
Security review detailsSecurity Blast Radius
Trust Boundaries and Controls
Resilience and Maintainability Implications
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Full details: Docstring CoverageExplanation Docstring coverage is 34.78% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 23 functions across 13 files. (2 skipped: 2 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 watched the worker run, Comment |
…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.
a296027 to
59edaa4
Compare
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.
Summary by CodeRabbit
exitevent is reported once after the worker finishes, with exit code0. Calls toterminate()now resolve when shutdown completes rather than immediately.