Skip to content

[$175] [Sentry: APP-M0W] HybridApp iOS: App Hang, Live Markdown parser blocks the main thread on the JS runtime lock #104744

Open

Description

@mountiny

Sentry

https://expensify.sentry.io/issues/APP-M0W

Impact (snapshot at filing)

  • Users (total since first seen): 21
  • Events: 21
  • Users (last 14d, all versions): 9
  • Users (last 14d, production 9.5.13 to 9.5.15): 3, all on 9.5.13-6
  • First seen: 2026-09-16
  • Last seen: 2026-10-21 (ongoing)
  • Platform: HybridApp iOS
  • App version(s): 9.5.2 to 9.5.13
  • Mechanism: AppHang (Fatal App Hang Fully Blocked, main thread blocked for at least 2000 ms), level fatal

Stack trace (main thread, top frames)

__psynch_mutexwait
std::__1::recursive_mutex::lock
facebook::jsi::WithRuntimeDecorator<T>::createStringFromUtf8
-[MarkdownParser parseUncached:withParserId:]
-[MarkdownParser parseIfRegistered:withParserId:]
-[MarkdownParser parse:withParserId:]
-[RCTMarkdownUtils applyMarkdownFormatting:withDefaultTextAttributes:]
-[MarkdownTextStorageDelegate textStorage:didProcessEditing:range:changeInLength:]
-[MarkdownTextInputDecoratorComponentView applyNewStyles]
-[MarkdownTextInputDecoratorComponentView updateProps:oldProps:]
RCTPerformMountInstructions (RCTMountingManager.mm:83)
...
reanimated::ReanimatedModuleProxy::executeLayoutAnimationsRequests
worklets::WorkletRuntime::runSyncAndDiscard<T>

Suspected cause

A reanimated layout animation runs a Fabric mount on the main thread. The mount updates the props of a MarkdownTextInput, and Live Markdown parses the text right away on the main thread. The parser calls into a JS runtime and waits for its lock, and another thread holds that lock, so the main thread stays blocked until the watchdog kills the app.

Reproduction

Unknown. See the Sentry events linked from the Sentry issue.

Related

  • Sibling Sentry IDs: none confirmed
  • Prior GH issues: none found
Upwork Automation - Do Not Edit
Issue OwnerCurrent Issue Owner: @TaduJR

Activity

  1. self-assigned this
    on Oct 22, 2026
  2. added
    ExternalAdded to denote the issue can be worked on by a contributor
    BugSomething is broken. Auto assigns a BugZero manager.
    on Oct 22, 2026
  3. melvin-bot commented on Oct 22, 2026

    @melvin-bot

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

  4. changed the title [-][Sentry: APP-M0W] HybridApp iOS: App Hang, Live Markdown parser blocks the main thread on the JS runtime lock[/-] [+][$175] [Sentry: APP-M0W] HybridApp iOS: App Hang, Live Markdown parser blocks the main thread on the JS runtime lock[/+] on Oct 22, 2026
  5. melvin-bot commented on Oct 22, 2026

    @melvin-bot
  6. Abdallah-1551 commented on Oct 22, 2026

    @Abdallah-1551

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    On HybridApp iOS, the app sometimes freezes for 2 seconds or more, and the iOS watchdog reports a fatal App Hang. The main thread is blocked inside -[MarkdownParser parseUncached:withParserId:]. It is waiting on a std::recursive_mutex that guards a JS (worklet) runtime, and another thread holds that lock. The call chain is:

    1. A Reanimated layout animation runs a Fabric mount on the main thread.
    2. The mount calls MarkdownTextInputDecoratorComponentView updateProps → applyNewStyles.
    3. applyNewStyles triggers textStorage:didProcessEditing, which synchronously calls the markdown parser.

    The goal is that the main thread never blocks on a JS runtime lock because of Live Markdown. At minimum, a props or style update on a MarkdownTextInput must not trigger a synchronous JS call.

    What is the root cause of that problem?

    This analysis is from the stack trace only. There is no reproduction, so the details below are strong hypotheses.

    1. The native parser calls into a JS runtime synchronously on the main thread. MarkdownParser.mm has to create JSI values (createStringFromUtf8) and run the parser worklet on a worklet runtime. That runtime is wrapped in a decorator that takes a recursive_mutex for every access. If any other thread is using the runtime, the main thread waits for it.
    2. The re-parse is triggered by a props update, not only by a text edit.
      • updateProps → applyNewStyles rewrites the attributes of the whole text storage, which fires didProcessEditing and re-runs the parser.
      • The parse result only contains ranges (type, location, length, depth). It does not depend on markdownStyle.
      • Re-parsing on a style-only or layout-driven prop update is therefore unnecessary. When the parse cache misses, as with a new or long text, the call goes to the JS runtime and can block.
    3. The runtime lock is held for a long time by another thread. A likely holder is the JS thread or the UI runtime running a long markdown parse worklet (long text, or parseExpensiMark invoked elsewhere via runOnRuntime/runOnJS). The Reanimated layout-animation path (executeLayoutAnimationsRequests → runSyncAndDiscard) makes this worse, because the mount runs inside a synchronous worklet-runtime section on the main thread. That creates a lock-ordering and contention window.

    What changes do you think we should make in order to solve the problem?

    1. Main fix, in react-native-live-markdown (native iOS). Do not call into JS when only styles or props change.

    • In MarkdownTextInputDecoratorComponentView, store the last parsed ranges together with the text and parserId they were computed from.
    • In applyNewStyles, if the text and parser are unchanged, re-apply attributes from the cached ranges and skip MarkdownParser parse:.
    // RCTMarkdownUtils.mm (sketch)
    - (NSAttributedString *)applyMarkdownFormatting:(NSAttributedString *)input
                          withDefaultTextAttributes:(NSDictionary *)defaults {
      NSString *text = input.string;
      NSArray *ranges;
      if ([text isEqualToString:_lastText] && _lastParserId == _parserId) {
        ranges = _lastRanges;                    // no JS call, no runtime lock
      } else {
        ranges = [_parser parse:text withParserId:_parserId]; // may hit JS
        _lastText = [text copy]; _lastParserId = _parserId; _lastRanges = ranges;
      }
      return [self applyRanges:ranges toText:input defaults:defaults];
    }

    This removes the hang path from the stack trace, where a layout-animation mount only changes props or styles. It is also a performance win.

    2. Defense in depth in react-native-live-markdown. For the unavoidable parse on the main thread (a real text change or the initial mount), avoid an unbounded wait on the runtime lock.

    • Use a dedicated worklet runtime for the markdown parser. It must be used only by Live Markdown and never shared with the UI runtime or user code.
    • Keep the existing parse cache and make sure it is populated before mount where possible. For example, pre-parse the defaultValue or value on the JS side.
    • If a non-blocking try-lock is available, fall back to plain unformatted text and schedule an async re-format on the next run loop tick instead of blocking.

    3. In Expensify/App.

    • Verify that the markdown parser (parseExpensiMark) is only executed on the dedicated markdown runtime. Remove any usage on the JS thread or UI runtime (runOnRuntime, runOnUI and similar).
    • Audit the screens in the stack trace (Composer, MarkdownTextInput inside Animated.View or LayoutAnimation). Do not apply Reanimated layout/entering/exiting to an ancestor that remounts or relayouts a MarkdownTextInput. Animate a wrapper or use plain transforms instead.
    • After the live-markdown fix is released, bump the @expensify/react-native-live-markdown version in App's package.json and update the patch if one exists.

    What alternative solutions did you explore? (Optional)

    • Increase the watchdog timeout or ignore the Sentry event: rejected, because the app is still frozen and gets killed.
    • Run parsing fully asynchronously in all cases: rejected for the main fix. Text would flash unstyled while the user types, and edits would need reconciling with stale results. It is kept only as a fallback for the contended case.
    • Replace the JS parser with a native (C++) parser: the most robust option, because it removes the runtime lock entirely. It is too large for this issue and is a possible follow-up.
    • Disable layout animations globally in App: rejected as overly broad and a UX regression. The targeted audit in step 3 is preferred.

    Testing

    1. Unit/native test (live-markdown): mount a MarkdownTextInput with text and spy on MarkdownParser parse:. Update only markdownStyle, layout props or the font. Assert that the parser is not called again and that the attributes are updated correctly. Change the text and assert that it is called once.
    2. Contention test: in the live-markdown example app, hold the markdown runtime lock on a background thread, for example with a long-running worklet via runOnRuntime. Trigger a layout animation that changes the props of a MarkdownTextInput. Before the fix the main thread blocks. After the fix there is no hang, and the UI stays responsive.
    3. Manual in HybridApp iOS: open a report with a long draft in the composer, then trigger navigation and layout animations such as opening and closing the emoji picker, attachments, and the keyboard. Confirm there are no freezes, and that markdown formatting (bold, links, mentions, code) is still highlighted correctly after style changes.
    4. Monitoring: after release, watch Sentry issue APP-M0W for new events on the fixed version. It should stop recurring.
  7. MelvinBot commented on Oct 22, 2026

    @MelvinBot
    Contributor

    Proposal

    What is the root cause of that problem?

    This is a deadlock between two worklet runtime locks. The issue title says "JS runtime lock," but the lock in the stack belongs to the LiveMarkdownRuntime worklet runtime, not the React Native JS runtime.

    1. The main thread holds the UI runtime lock and waits for the markdown runtime lock. Reanimated flushes layout animations inside runSyncAndDiscard on the UI runtime. That flush mounts pending Fabric changes on the main thread. When a MarkdownTextInput mounts, updateProps calls applyNewStyles, which calls the two-argument applyMarkdownFormatting. That method parses on the main thread without checking the cache first (RCTMarkdownUtils.mm:20-35). Fix main thread hang on iOS made only the four-argument (measure) method safe to call on the main thread (RCTMarkdownUtils.mm:53-79).
    2. Another thread holds the markdown runtime lock and waits for the UI runtime lock. Our parser worklet reads mentionsSharedVal.get() (RNMarkdownTextInput.tsx:70). On a worker runtime, reading a shared value calls getSync(), which makes a synchronous call into the UI runtime (mutables.native.ts:33-34). That call needs the UI runtime lock, which the main thread already holds.

    Each thread waits for the other forever, so the watchdog kills the app.

    What changes do you think we should make in order to solve the problem?

    App change (the main fix): stop reading a Reanimated shared value inside the parser worklet. In RNMarkdownTextInput.tsx, replace mentionsSharedVal with a value the markdown runtime can read without calling the UI runtime. Two options:

    • Store the mentions list in a global on the markdown runtime. The existing scheduleOnLiveMarkdownRuntime effect sets the global, and the parser reads it directly.
    • Capture availableLoginsList in the worklet closure, so the parser is registered again when the list changes.

    The rule to follow: a parser worklet must never use .get() or .value on a shared value.

    Library change (follow-up hardening): in react-native-live-markdown, make the two-argument applyMarkdownFormatting behave like the four-argument one when it runs on the main thread:

    1. Read only the cached result.
    2. Warm the cache on a background thread.
    3. Apply the formatting again when parsing finishes.
    Investigation details
    • Which runtime and lock: the parser runs on a dedicated LiveMarkdownRuntime worker runtime. Locking is on by default for worker runtimes. Each runtime has its own recursive_mutex, which WithRuntimeDecorator<AroundLock> locks on every JSI call. That lock is the createStringFromUtf8 → recursive_mutex::lock frame in the stack.
    • Why the cache misses: the shadow node and the component view each have their own RCTMarkdownUtils and parser cache. Background parsing during layout never fills the component view's cache. RCTMountingManager.mm:83 is the Insert mutation, so the input is newly mounted or recycled and its cache is empty.
    • No animated parent is needed: I found no markdown input inside a component with entering, exiting, or layout props. A layout animation flush mounts every pending mutation on the surface. So any running layout animation can trigger the hang while a composer mounts, for example FadeIn on Search pages, AnimatedExitRow, or ReanimatedModal.
    • Likely introducing change: Bump react-native-live-markdown (merged 2026-08-29) brought in live-markdown 0.1.336, which includes Fix main thread hang on iOS. The parseUncached: frame first appears in that version, and that PR also added the background cache-warmup queue, which is a likely holder of the markdown lock. First seen 2026-09-16 on 9.5.2 fits this timeline. The Sentry event for the earlier hang (APP-EF1) showed the same pattern: parse → runSync → runSyncSerialized → recursive_mutex::lock. That PR removed an Objective-C lock but left this lock-order problem in place.
    • No upstream fix yet: live-markdown 0.1.344 only bumps Node.
    • Not verified: I couldn't open the second thread's stack in Sentry. I expect it to be the com.expensify.livemarkdown.parser-cache-warmup queue or the JS thread, stuck in runSyncSerialized → recursive_mutex::lock. The thread dump in APP-M0W would confirm this.

    What alternative solutions did you explore? (Optional)

    • Library change only: this stops the main thread from blocking. But a parse on a background thread can still block on the UI runtime, so the App change is still needed.
    • Turn off locking on LiveMarkdownRuntime: this is unsafe, because the parser runs on several threads.

    Confidence: medium. The source code supports this explanation, but I haven't seen the Sentry thread dump. I couldn't reproduce the hang because it needs HybridApp on iOS, and this run can only test on web.


    Next Steps for Contributor+ team:
    To accept: @MelvinBot implement [this](https://github.com/Expensify/App/issues/104744) to create a draft PR.
    To refine: @MelvinBot <your feedback>
    To reject: Explain why you are rejecting Melvin's proposal.


    view run

  8. camesenin commented on Oct 22, 2026

    @camesenin

    Contributor details
    Your Expensify account email: [email protected]
    Upwork Profile Link: https://www.upwork.com/freelancers/~0170d6b02e9440e604

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    On HybridApp iOS, the app encounters a fatal App Hang freeze (>2 seconds), reported in Sentry as APP-M0W. The main thread hangs inside -[MarkdownParser parseUncached:withParserId:] waiting on std::recursive_mutex::lock for a JS worklet runtime while a Fabric mount is running during a layout animation.

    What is the root cause of that problem?

    1. Cross-Runtime Deadlock:
      • In src/components/RNMarkdownTextInput.tsx:70, the parser worklet executes on the dedicated LiveMarkdownRuntime:
        return parseExpensiMarkWithShortMentions(text, mentionsSharedVal.get(), currentUserMentions);
      • Notice that mentionsSharedVal is a Reanimated SharedValue (useSharedValue(availableLoginsList)). Reanimated SharedValues belong to the UI runtime.
      • Calling mentionsSharedVal.get() inside a worklet running on LiveMarkdownRuntime triggers a synchronous cross-runtime call into the UI runtime while holding LiveMarkdownRuntime's lock.
    2. Fabric Mount Contention Window:
      • During a layout animation (or when executeLayoutAnimationsRequests runs on the main thread), the UI runtime and main thread hold the Reanimated UI lock and trigger Fabric mount mutations.
      • When MarkdownTextInputDecoratorComponentView updateProps -> applyNewStyles fires, it calls textStorage:didProcessEditing, which synchronously invokes -[MarkdownParser parseUncached:withParserId:] on the main thread.
      • The main thread waits to acquire the LiveMarkdownRuntime lock. Meanwhile, a background parse already holding the LiveMarkdownRuntime lock attempts to acquire the UI runtime lock via mentionsSharedVal.get().
      • Classic AB-BA deadlock between two mutexes occurs, freezing the main thread until the iOS watchdog terminates the process.

    What changes do you think we should make in order to solve the problem?

    In src/components/RNMarkdownTextInput.tsx:
    Eliminate reading the Reanimated mentionsSharedVal from within the parser worklet. Store the mentions list directly in a global property scoped to the LiveMarkdownRuntime:

    --- a/src/components/RNMarkdownTextInput.tsx
    +++ b/src/components/RNMarkdownTextInput.tsx
    @@ -35,7 +35,6 @@ function RNMarkdownTextInputWithRef({maxLength, parser, ref, forwardedFSClass =
         const styles = useThemeStyles();
    
         const {availableLoginsList, currentUserMentions} = useShortMentionsList();
    -    const mentionsSharedVal = useSharedValue<string[]>(availableLoginsList);
         const inputRef = useRef<AnimatedMarkdownTextInputRef>(null);
    
         // Expose the ref to the parent component
    @@ -67,20 +66,23 @@ function RNMarkdownTextInputWithRef({maxLength, parser, ref, forwardedFSClass =
                 if (parser) {
                     return parser(text);
                 }
    
    -            return parseExpensiMarkWithShortMentions(text, mentionsSharedVal.get(), currentUserMentions);
    +            const mentions = (global as unknown as {liveMarkdownMentions?: string[]}).liveMarkdownMentions ?? [];
    +            return parseExpensiMarkWithShortMentions(text, mentions, currentUserMentions);
             },
    -        [currentUserMentions, mentionsSharedVal, parser],
    +        [currentUserMentions, parser],
         );
    
         useEffect(() => {
             scheduleOnLiveMarkdownRuntime(() => {
                 'worklet';
    
    -            mentionsSharedVal.set(availableLoginsList);
    +            (global as unknown as {liveMarkdownMentions?: string[]}).liveMarkdownMentions = availableLoginsList;
             });
    -    }, [availableLoginsList, mentionsSharedVal]);
    +    }, [availableLoginsList]);

    Why this solution is optimal:

    1. Completely breaks the deadlock cycle: The parser worklet reads liveMarkdownMentions directly from its own global memory on LiveMarkdownRuntime. It never calls into the Reanimated UI runtime, eliminating the AB-BA lock inversion.
    2. Zero native patch overhead: Fixes the hang cleanly in application code without requiring complex native C++ / Objective-C patches.
    3. Preserves mention parsing: Mentions remain fully reactive; whenever availableLoginsList changes, scheduleOnLiveMarkdownRuntime synchronously updates the global store.

    What alternative solutions did you explore? (Optional)

    • Caching ranges in RCTMarkdownUtils.mm only:
      Rejected as a primary fix. While caching avoids re-parsing unchanged text, initial mounts or active user keystrokes still run parseUncached and would deadlock whenever layout animations coincide with user typing. Eliminating the cross-runtime SharedValue.get() call is essential.

    What automated tests can you add to prevent this from re-occurring?

    In tests/unit/RNMarkdownTextInputTest.tsx:

    • Render RNMarkdownTextInput and execute the parserWorklet.
    • Verify that parseExpensiMarkWithShortMentions runs successfully without reading Reanimated.useSharedValue.
    • Update availableLoginsList and verify that updated mentions are parsed on subsequent worklet executions.
  9. melvin-bot commented on Oct 22, 2026

    @melvin-bot

    ✅ Contributor details stored successfully. Thank you for contributing to Expensify!

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.DailyKSv2ExternalAdded to denote the issue can be worked on by a contributor

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions