Repository navigation
[$250] [Sentry: APP-7CX] HybridApp iOS Fatal App Hang in YAPLJS callFunction during onBarButtonItemSelected (still occurring after #86334 closed) #91229
Description
Sentry
https://expensify.sentry.io/issues/APP-7CX
Impact (snapshot at filing)
- Users (total since first seen): 3,395
- Events: 3,627
- Users (last 14d): ~86
- First seen: 2026-03-11
- Last seen: now (ongoing)
- Platform: iOS, hybrid_app (HybridApp / OldDot)
- App version(s): 9.3.59 and later (still occurring on 9.3.77)
- Device class: high (iPhone15+, iOS 26.x dominant)
- Mechanism:
AppHang, fatal, watchdog termination after 2000ms main-thread block
Stack trace (top frames, native iOS)
-[UIControl _sendActionsForEvents:withEvent:]
→ -[UIBarButtonItem _triggerActionForEvent:fallbackSender:]
→ -[BaseViewController onBarButtonItemSelected:]
→ +[YAPLJS callFunction:args:]
→ -[JSValue callWithArguments:]
→ JSObjectCallAsFunction
→ JSC::profiledCall
→ JSC::Interpreter::executeCall
→ (... JS execution ...)
→ +[YAPLLogManager logWithLevel:message:parameters:tags:syncWithServer:isInsecure:]
→ +[YAPLLogManager commitLogLine:...]
→ LogSyncerManager.addLog
→ LogSyncerManager.sendLogPacket
→ +[YAPLJS callFunction:args:] ← second JS entry from inside the first
→ -[JSValue callWithArguments:]
→ JSC::JSLock::grabAllLocks
→ JSC::JSLock::lock
→ WTF::LockAlgorithm<…>::lockSlow
→ WTF::ParkingLot::parkConditionallyImpl
→ WTF::ThreadCondition::timedWait
→ _pthread_cond_wait ← main thread blocked here
Suspected cause
JSC lock contention deadlock. The main thread enters JS through YAPLJS callFunction:args: to handle the bar button tap, the JS handler calls Log (or another function that goes through YAPLLogManager), and LogSyncerManager.sendLogPacket re-enters JSC via a second +[YAPLJS callFunction:args:]. The second entry contends for the JSC lock held by the first entry on the same thread, the parking lot puts the thread to sleep, and the watchdog terminates the app at the 2000ms threshold.
Avoiding re-entrant JSC calls inside LogSyncerManager.sendLogPacket (e.g. by dispatching the second call to a serial queue rather than executing it inline) is the most likely fix direction.
Reproduction
Unknown deterministically. Triggers on bar button taps when the resulting JS handler logs something that causes a re-entrant native-to-JS call.
Related
- Sibling Sentry IDs: APP-7A5 (tracked by closed #86334 — closed 2026-05-18 as COMPLETED, but APP-7CX continues to fire on the same symptom)
- Prior GH issues: #86334 (closed). This issue covers the still-occurring Sentry grouping; reopening Fatal App Hang Fully Blocked: The user or the OS watchdog terminated your app while it blocked the main thread for at least 2000 ms. #86334 is also acceptable if maintainers prefer that.
Two more Sentry groupings hit the same OldDot iOS bar-button-tap path — flagging here so they can be tracked under one fix:
| Sentry ID | Users (last 14d) | Signature |
|---|---|---|
| APP-7CX (this issue) | ~86 | Fatal App Hang +[YAPLJS callFunction:args:] |
| APP-84S | ~20 | Same Fatal App Hang +[YAPLJS callFunction:args:] signature |
| APP-82E | ~15 | NSRangeException objectAtIndexedSubscript index 0 beyond bounds for empty array at -[BaseViewController onBarButtonItemSelected:] (same culprit frame in this issue's stack) |
Same root-cause family as far as the stack shows — recursive JSC invocation triggered from a bar-button tap handler. A fix here should resolve all three.
Upwork Automation - Do Not Edit
- Upwork Job URL: https://www.upwork.com/jobs/~022057380809520139975
- Upwork Job ID: 2057380809520139975
- Last Price Increase: 2026-06-18
Issue Owner
Current Issue Owner: @mateuuszzzzzMetadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity
MelvinBot commentedon May 21, 2026
Proposal
What is the root cause of that problem?
This is a JSC lock re-entrancy deadlock in the OldDot/YAPL native iOS bridge, distinct from the FullStory main-thread blocking that #86334 addressed.
The crash path (from the Sentry stack trace in the issue body):
UIBarButtonItem→BaseViewController.onBarButtonItemSelectedcallsYAPLJS.callFunction:args:, which acquires the JSC lock and enters JavaScriptCore on the main thread.YAPLLogManager.logWithLevel:message:...→YAPLLogManager.commitLogLine:→LogSyncerManager.addLog→LogSyncerManager.sendLogPacket.sendLogPacketsynchronously callsYAPLJS.callFunction:args:again (likely to invoke a JS-side log serialization or network call).callFunctiontries to acquire the JSC lock, which is already held by step 1 on the same thread →JSC::JSLock::lockSlow→WTF::ParkingLot::parkConditionallyImpl→ main thread sleeps waiting for itself → deadlock.This is not the same bug as #86334. That issue was caused by FullStory's
PopulateInclusiveFramesForLayerview-hierarchy scan blocking the main thread, and was addressed by bumping the FullStory SDK. APP-7CX has a completely different stack trace involvingLogSyncerManagerre-entering JSC synchronously — no FullStory frames involved.All relevant code (
YAPLJS,LogSyncerManager,YAPLLogManager,BaseViewController) lives in the private Mobile-Expensify repository underiOS/andapp/.What changes do you think we should make in order to solve the problem?
The fix should be made in Mobile-Expensify (not App).
LogSyncerManager.sendLogPacketmust not synchronously re-enter JSC on the calling thread. Two viable approaches:Dispatch to a serial queue — When
sendLogPacketneeds to call back into JS (viaYAPLJS.callFunction), dispatch that call to a dedicated serial background queue (dispatch_async) instead of executing it inline. This breaks the re-entrant lock acquisition on the main thread.Queue-and-drain — Instead of sending the log packet immediately, enqueue it and drain the queue on the next run loop tick (
dispatch_async(dispatch_get_main_queue(), ...)) or from a background queue. This also avoids nested JSC entry.Option 1 is preferred because it also moves log-syncing work off the main thread entirely, reducing future app-hang risk.
What alternative solutions did you explore? (Optional)
YAPLJS.callFunction: Could detect same-thread re-entry and queue the call instead of blocking. However, this changes the semantics ofcallFunctionglobally and could introduce subtle ordering bugs in other call sites. Fixing at theLogSyncerManagerlevel is more targeted and safer.App/can address this.Supporting evidence
Distinct from #86334:
YAPLJS,LogSyncerManager, orYAPLLogManager.UIBarButtonItem→YAPLJS→YAPLLogManager→LogSyncerManager→YAPLJS(re-entry).Code location confirmed:
YAPLJS,LogSyncerManager,YAPLLogManager, andBaseViewControllerare all inMobile-Expensify/iOS/(the Expensify Classic native iOS code, per HYBRID-APP.md).Mobile-Expensify/app/(per HYBRID-APP.md L21).Impact: 3,395 total users, ~86 in the last 14 days, ongoing on iOS 9.3.59 through 9.3.77. Affects HybridApp/OldDot users exclusively (the YAPLJS bridge is only used in Expensify Classic mode).
Next Steps for Contributor+ team: Reply with
@MelvinBot implement thisto create a draft PR,@MelvinBot <your feedback>to refine this analysis, or explain why you are rejecting Melvin's proposal. Note: implementation requires access to the private Mobile-Expensify repo.KJ21-ENG commentedon May 21, 2026
Proposal
Please re-state the problem that we are trying to solve in this issue.
On iOS HybridApp / OldDot, tapping a native bar button can fatally hang the app. The Sentry stack shows the main thread entering JS from
BaseViewController onBarButtonItemSelected:, then logging from that JS path, then re-entering JS again fromLogSyncerManager.sendLogPacketwhile the first JS call is still active. The second JavaScriptCore entry blocks onJSC::JSLock, so the main thread sleeps long enough for the 2s watchdog to kill the app.What is the root cause of that problem?
The problematic code path is re-entrant native-to-JS execution inside the HybridApp/OldDot logging flush.
The first entry is the bar-button tap bridge:
During that JS handler, logging synchronously enters the native log manager and log syncer:
Then
sendLogPacketcalls back into JS immediately:That is the unsafe part: the app is already inside a
YAPLJS callFunction:args:frame on the main thread, and the logging syncer tries to run another JS function inline before the outer call has unwound. The issue body's stack ends in JavaScriptCore lock contention / timed wait, which matches a same-thread re-entry deadlock rather than a slow network request or a React render.The App repo does not contain the OldDot
YAPLJS,YAPLLogManager, orLogSyncerManagerimplementation, but it does confirm this build ships a local HybridApp bridge and shared logging plumbing.@expensify/react-native-hybrid-appis a local module in this repo, and the native iOS pod resolves it from that local package:App/package.json
Lines 82 to 85 in eaaffe3
App/ios/Podfile.lock
Lines 4709 to 4712 in eaaffe3
On the NewDot side, the logger callback sends log packets through the
LogAPI command and also mirrors native/app-group logs back into JSLog.info/Log.hmmm, so logging is a legitimate bridge participant in HybridApp rather than an unrelated frame in the stack:App/src/libs/Log.ts
Lines 21 to 28 in eaaffe3
App/src/libs/Log.ts
Lines 44 to 82 in eaaffe3
App/src/libs/Log.ts
Lines 121 to 138 in eaaffe3
So the root cause is not the bar button itself. It is that
LogSyncerManager.sendLogPacketperforms a nested synchronousYAPLJS.callFunctionwhile a previousYAPLJS.callFunctionis still executing for the same user action.What changes do you think we should make in order to solve the problem?
Move the JS callback performed by
LogSyncerManager.sendLogPacketout of the currentYAPLJS.callFunctionstack.Concretely, in the HybridApp/OldDot native logging code:
isDrainingLogPacketsflag.sendLogPacketis invoked while native code is already insideYAPLJS.callFunction, enqueue the packet and return immediately instead of callingYAPLJS.callFunctioninline.LogSyncerManager.YAPLJS.callFunctionboundary if one does not already exist, so any future native path that tries to call into JS while JS is active gets queued instead of blocking the main thread on JavaScriptCore.LogSyncerManager.sendLogPacketrun during that callback, and verify the nested JS call is deferred until after the outer call returns.The important implementation detail is that the fix should not simply move all logging to a background thread and then call JavaScriptCore from there. JSC contexts are thread-sensitive, and this stack is specifically a lock/re-entry failure. The safe behavior is serialized, non-re-entrant JS delivery on the expected JS execution queue.
What alternative solutions did you explore? (Optional)
Debouncing bar button taps would reduce frequency but would not fix the underlying re-entrant logging path; any JS handler that logs could still hit the same
YAPLJS -> YAPLLogManager -> LogSyncerManager -> YAPLJScycle.Making the JS
Logcalls asynchronous in NewDot is also too narrow. The Sentry stack is in the OldDotYAPLLogManager/LogSyncerManagernative path, and the deadlock happens when native logging re-entersYAPLJS. The durable fix belongs at the native log-packet bridge /YAPLJS.callFunctionboundary so all logging-triggered re-entry is serialized in one place.melvin-bot commentedon May 21, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @Ollyws (
External)melvin-bot commentedon May 21, 2026
Job added to Upwork: https://www.upwork.com/jobs/~022057380809520139975
[-][Sentry: APP-7CX] HybridApp iOS Fatal App Hang in YAPLJS callFunction during onBarButtonItemSelected (still occurring after #86334 closed)[/-][+][$250] [Sentry: APP-7CX] HybridApp iOS Fatal App Hang in YAPLJS callFunction during onBarButtonItemSelected (still occurring after #86334 closed)[/+]mountiny commentedon May 21, 2026
Two more Sentry groupings hit the same OldDot iOS bar-button-tap path — flagging here so they can be tracked under one fix:
Fatal App Hang +[YAPLJS callFunction:args:]Fatal App Hang +[YAPLJS callFunction:args:]signatureNSRangeException objectAtIndexedSubscript index 0 beyond bounds for empty arrayat-[BaseViewController onBarButtonItemSelected:](same culprit frame in this issue's stack)Same root-cause family as far as the stack shows — recursive JSC invocation triggered from a bar-button tap handler. A fix here should resolve all three.
KJ21-ENG commentedon May 21, 2026
Thanks for flagging, @mountiny. This matches my existing RCA: the fix should serialize/defer the OldDot bar-button-path
YAPLJScallback fromLogSyncerManagerso we do not recursively enter JSC during the same tap handler.That should directly cover APP-7CX and APP-84S. For APP-82E, since the top-level signature is different, I’d validate it under the same
BaseViewController.onBarButtonItemSelectedfix family rather than assuming it is the exact same JSC deadlock.47 remaining items
mateuuszzzzz commentedon Jul 17, 2026
@mountiny just to confirm, does this update counts APP-82E as well? I checked Sentry and we don't have any APP-7CX on 9.4.32-9.4.34
melvin-bot commentedon Aug 10, 2026
This issue has not been updated in over 15 days. @Ollyws, @mountiny, @mateuuszzzzz eroding to Monthly issue.
P.S. Is everyone reading this sure this is really a near-term priority? Be brave: if you disagree, go ahead and close it out. If someone disagrees, they'll reopen it, and if they don't: one less thing to do!
mountiny commentedon Sep 9, 2026
@mateuuszzzzz seems like this is still happening in the latest versions
can you take a look?
mateuuszzzzz commentedon Sep 10, 2026
I investigated the issue, so far it looks like different problem, but grouped under the same tag
mateuuszzzzz commentedon Sep 11, 2026
Digging into this further today
mateuuszzzzz commentedon Sep 16, 2026
Initially, I assumed we were dealing with a callstack similar to the one from the original APP-7CX issue. However, it turns out to be exactly the same callstack.
What’s more, all the versions visible here were released before my fix was merged (9.4.18-2). I think the problem is that the affected users are OldDot users. Unfortunately, we cannot display a modal prompting them to update the app, so they remain on older versions.
@mountiny we don’t really have any actionable items here, except maybe reaching out to those users or adding a similar modal to OldDot? But again they need to update the app first to have version with update modal 😅
mountiny commentedon Sep 17, 2026
hmm yeah we dont have a way to force upgrade those unfortunately
ok so we can consider this done I guess
mountiny commentedon Sep 17, 2026
Seems like there is no payment here