Skip to content

[$250] [Sentry: APP-7CX] HybridApp iOS Fatal App Hang in YAPLJS callFunction during onBarButtonItemSelected (still occurring after #86334 closed) #91229

Closed

Description

@mountiny

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

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
Issue OwnerCurrent Issue Owner: @mateuuszzzzz

Activity

  1. self-assigned this
    on May 21, 2026
  2. added
    BugSomething is broken. Auto assigns a BugZero manager.
    on May 21, 2026
  3. MelvinBot commented on May 21, 2026

    @MelvinBot
    Contributor

    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):

    1. User taps a UIBarButtonItem → BaseViewController.onBarButtonItemSelected calls YAPLJS.callFunction:args:, which acquires the JSC lock and enters JavaScriptCore on the main thread.
    2. The YAPL JS handler triggers logging → YAPLLogManager.logWithLevel:message:... → YAPLLogManager.commitLogLine: → LogSyncerManager.addLog → LogSyncerManager.sendLogPacket.
    3. sendLogPacket synchronously calls YAPLJS.callFunction:args: again (likely to invoke a JS-side log serialization or network call).
    4. The second callFunction tries 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.
    5. iOS watchdog terminates the app after 2000ms of main-thread block.

    This is not the same bug as #86334. That issue was caused by FullStory's PopulateInclusiveFramesForLayer view-hierarchy scan blocking the main thread, and was addressed by bumping the FullStory SDK. APP-7CX has a completely different stack trace involving LogSyncerManager re-entering JSC synchronously — no FullStory frames involved.

    All relevant code (YAPLJS, LogSyncerManager, YAPLLogManager, BaseViewController) lives in the private Mobile-Expensify repository under iOS/ and app/.

    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.sendLogPacket must not synchronously re-enter JSC on the calling thread. Two viable approaches:

    1. Dispatch to a serial queue — When sendLogPacket needs to call back into JS (via YAPLJS.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.

    2. 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)

    • Re-entrant lock / recursion guard in YAPLJS.callFunction: Could detect same-thread re-entry and queue the call instead of blocking. However, this changes the semantics of callFunction globally and could introduce subtle ordering bugs in other call sites. Fixing at the LogSyncerManager level is more targeted and safer.
    • Fixing in App repo: Not viable — the entire crash path is in Mobile-Expensify native code. No changes to App/ can address this.
    Supporting evidence

    Distinct from #86334:

    Code location confirmed:

    • YAPLJS, LogSyncerManager, YAPLLogManager, and BaseViewController are all in Mobile-Expensify/iOS/ (the Expensify Classic native iOS code, per HYBRID-APP.md).
    • The YAPL JS logic lives in 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 this to 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.

  4. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on May 21, 2026
  5. added
    Help WantedApply this label when an issue is open to proposals by contributors
    on May 21, 2026
  6. KJ21-ENG commented on May 21, 2026

    @KJ21-ENG
    Contributor

    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 from LogSyncerManager.sendLogPacket while the first JS call is still active. The second JavaScriptCore entry blocks on JSC::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:

    -[BaseViewController onBarButtonItemSelected:]
      -> +[YAPLJS callFunction:args:]
    

    During that JS handler, logging synchronously enters the native log manager and log syncer:

    +[YAPLLogManager logWithLevel:...]
      -> +[YAPLLogManager commitLogLine:...]
      -> LogSyncerManager.addLog
      -> LogSyncerManager.sendLogPacket
    

    Then sendLogPacket calls back into JS immediately:

    LogSyncerManager.sendLogPacket
      -> +[YAPLJS callFunction:args:]
      -> JSC::JSLock::lock
    

    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, or LogSyncerManager implementation, but it does confirm this build ships a local HybridApp bridge and shared logging plumbing. @expensify/react-native-hybrid-app is 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

    "@expensify/nitro-utils": "file:./modules/ExpensifyNitroUtils",
    "@expensify/react-native-background-task": "file:./modules/background-task",
    "@expensify/react-native-hybrid-app": "file:./modules/hybrid-app",
    "@expensify/react-native-live-markdown": "0.1.324",

    App/ios/Podfile.lock

    Lines 4709 to 4712 in eaaffe3

    ReactNativeBiometrics:
    :path: "../node_modules/@sbaiahmed1/react-native-biometrics"
    ReactNativeHybridApp:
    :path: "../node_modules/@expensify/react-native-hybrid-app"

    On the NewDot side, the logger callback sends log packets through the Log API command and also mirrors native/app-group logs back into JS Log.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

    function LogCommand(parameters: LogCommandParameters): Promise<{requestID: string}> {
    const commandName = 'Log';
    requireParameters(['logPacket', 'expensifyCashAppVersion'], parameters, commandName);
    // Note: We are forcing Log to run since it requires no authToken and should only be queued when we are offline.
    // Non-cancellable request: during logout, when requests are cancelled, we don't want to cancel any remaining logs
    return post(commandName, {...parameters, forceNetworkRequest: true, canCancel: false}) as Promise<{requestID: string}>;
    }

    App/src/libs/Log.ts

    Lines 44 to 82 in eaaffe3

    function serverLoggingCallback(logger: Logger, params: ServerLoggingCallbackOptions): Promise<{requestID: string}> {
    const baseParams = {
    shouldProcessImmediately: false,
    shouldRetry: false,
    expensifyCashAppVersion: `expensifyCash[${getPlatform()}]${pkg.version}`,
    };
    // Parse log lines and group by email to handle multi-user scenarios
    // (e.g., user signs out and another signs in before logs flush)
    const logLines = JSON.parse(params.logPacket) as LogLine[];
    const logsByEmail = new Map<string | null, LogLine[]>();
    for (const line of logLines) {
    const email = line.email ?? null;
    const existing = logsByEmail.get(email) ?? [];
    existing.push(line);
    logsByEmail.set(email, existing);
    }
    // Create a request for each email group
    const requests: Array<Promise<{requestID: string}>> = [];
    for (const [email, lines] of logsByEmail) {
    const requestParams: RequestParams = {
    ...params,
    ...baseParams,
    logPacket: JSON.stringify(lines),
    email,
    };
    if (requestParams.parameters) {
    requestParams.parameters = JSON.stringify(requestParams.parameters);
    }
    requests.push(LogCommand(requestParams));
    }
    // Mirror backend log payload into Telemetry logger for better context
    forwardLogsToSentry(params.logPacket);
    clearTimeout(timeout);
    timeout = setTimeout(() => logger.info('Flushing logs older than 10 minutes', true, {}, true), 10 * 60 * 1000);

    App/src/libs/Log.ts

    Lines 121 to 138 in eaaffe3

    // eslint-disable-next-line no-restricted-properties
    const appGroupName = HybridAppModule.isHybridApp() ? 'group.com.expensify' : 'group.com.expensify.new';
    AppLogs.configure({appGroupName, interval: -1});
    AppLogs.registerHandler({
    filter: '[NotificationService]',
    handler: ({filter, logs}) => {
    for (const log of logs) {
    // Both native and JS logs are captured by the filter so we replace the filter before logging to avoid an infinite loop
    const message = `[PushNotification] ${log.message.replace(filter, 'NotificationService -')}`;
    if (log.level === 'error') {
    Log.hmmm(message);
    } else {
    Log.info(message);
    }
    }
    },
    });

    So the root cause is not the bar button itself. It is that LogSyncerManager.sendLogPacket performs a nested synchronous YAPLJS.callFunction while a previous YAPLJS.callFunction is 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.sendLogPacket out of the current YAPLJS.callFunction stack.

    Concretely, in the HybridApp/OldDot native logging code:

    1. Add a small serialization layer around log-packet JS delivery, for example a dedicated serial dispatch queue or main-queue async hop guarded by an isDrainingLogPackets flag.
    2. When sendLogPacket is invoked while native code is already inside YAPLJS.callFunction, enqueue the packet and return immediately instead of calling YAPLJS.callFunction inline.
    3. Drain the queued packets asynchronously after the outer JS invocation has unwound. The drain should call the same existing JS log-send function, preserve ordering, and keep the existing retry/drop semantics from LogSyncerManager.
    4. Add a re-entry guard at the YAPLJS.callFunction boundary 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.
    5. Add a focused regression test or debug assertion around the exact shape: simulate a bar-button JS callback that logs, make LogSyncerManager.sendLogPacket run 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 -> YAPLJS cycle.

    Making the JS Log calls asynchronous in NewDot is also too narrow. The Sentry stack is in the OldDot YAPLLogManager / LogSyncerManager native path, and the deadlock happens when native logging re-enters YAPLJS. The durable fix belongs at the native log-packet bridge / YAPLJS.callFunction boundary so all logging-triggered re-entry is serialized in one place.

  7. melvin-bot commented on May 21, 2026

    @melvin-bot

    Triggered auto assignment to Contributor-plus team member for initial proposal review - @Ollyws (External)

  8. melvin-bot commented on May 21, 2026

    @melvin-bot
  9. changed the title [-][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)[/+] on May 21, 2026
  10. mountiny commented on May 21, 2026

    @mountiny
    ContributorAuthor

    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.

  11. KJ21-ENG commented on May 21, 2026

    @KJ21-ENG
    Contributor

    Thanks for flagging, @mountiny. This matches my existing RCA: the fix should serialize/defer the OldDot bar-button-path YAPLJS callback from LogSyncerManager so 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.onBarButtonItemSelected fix family rather than assuming it is the exact same JSC deadlock.

  12. 47 remaining items

  13. mateuuszzzzz commented on Jul 17, 2026

    @mateuuszzzzz
    Contributor

    @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

  14. melvin-bot commented on Aug 10, 2026

    @melvin-bot

    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!

  15. mountiny commented on Sep 9, 2026

    @mountiny
    ContributorAuthor

    @mateuuszzzzz seems like this is still happening in the latest versions

    Image

    can you take a look?

  16. mateuuszzzzz commented on Sep 10, 2026

    @mateuuszzzzz
    Contributor

    I investigated the issue, so far it looks like different problem, but grouped under the same tag

  17. mateuuszzzzz commented on Sep 11, 2026

    @mateuuszzzzz
    Contributor

    Digging into this further today

  18. mateuuszzzzz commented on Sep 16, 2026

    @mateuuszzzzz
    Contributor

    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 😅

  19. mountiny commented on Sep 17, 2026

    @mountiny
    ContributorAuthor

    hmm yeah we dont have a way to force upgrade those unfortunately

    ok so we can consider this done I guess

  20. mountiny commented on Sep 17, 2026

    @mountiny
    ContributorAuthor

    Seems like there is no payment here

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

BugSomething is broken. Auto assigns a BugZero manager.ExternalAdded to denote the issue can be worked on by a contributorMonthlyKSv2

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions