Skip to content

Fix main thread hang on iOS - #772

Open
staszekscp wants to merge 2 commits into
Expensify:mainfrom
staszekscp:fix/apple-markdown-main-thread-hang
Open

Fix main thread hang on iOS#772
staszekscp wants to merge 2 commits into
Expensify:mainfrom
staszekscp:fix/apple-markdown-main-thread-hang

Conversation

@staszekscp

Copy link
Copy Markdown
Contributor

Details

On iOS, RCTMarkdownUtils and MarkdownParser nest an Objective-C lock outside the markdown worklet runtime's recursive_mutex. That ordering lets a background thread hold the ObjC lock while it waits on the runtime mutex, which blocks the main thread in objc_sync_enter during Yoga measure until the iOS watchdog kills the app.

Today's code:

// RCTMarkdownUtils.mm
@synchronized (self) {
  _markdownStyle = markdownStyle;
  _parserId = parserId;
  [self applyMarkdownFormatting:...];   // -> MarkdownParser parse: -> runSync into the runtime
}

// MarkdownParser.mm
@synchronized (self) {
  ...
  output = markdownRuntime->runGuarded(markdownWorklet, input);   // blocks on recursive_mutex
  ...
}

Both locks are taken before entering the runtime, and the parse is synchronous, so the lock is held for the entire duration of the worklet call.

The two threads in the captured hang:

com.facebook.react.runtime.JavaScript — holds both @synchronized locks, blocked on the runtime mutex:

facebook::yoga::Node::measure
MarkdownTextInputDecoratorShadowNode::yogaNodeMeasureCallbackConnector
MarkdownTextInputDecoratorShadowNode::measureContent
MarkdownTextInputDecoratorShadowNode::applyMarkdownFormattingToTextInputState
-[RCTMarkdownUtils applyMarkdownFormatting:withDefaultTextAttributes:markdownStyle:parserId:]
-[RCTMarkdownUtils applyMarkdownFormatting:withDefaultTextAttributes:]
-[MarkdownParser parse:withParserId:]
worklets::WorkletRuntime::runSync<T>
...
worklets::WorkletRuntime::runSyncSerialized<T>
std::__1::recursive_mutex::lock
_pthread_mutex_firstfit_lock_slow
__psynch_mutexwait            <-- blocked here

Main thread — same measure path, blocked acquiring the lock the thread above is holding:

facebook::react::YogaLayoutableShadowNode::layoutTree
...
facebook::yoga::Node::measure
MarkdownTextInputDecoratorShadowNode::yogaNodeMeasureCallbackConnector
MarkdownTextInputDecoratorShadowNode::measureContent
MarkdownTextInputDecoratorShadowNode::applyMarkdownFormattingToTextInputState
-[RCTMarkdownUtils applyMarkdownFormatting:withDefaultTextAttributes:markdownStyle:parserId:]
objc_sync_enter
_os_unfair_lock_lock_slow
__ulock_wait2                 <-- blocked here

Fatal App Hang Fully Blocked — main thread blocked ≥ 2000 ms, culprit -[RCTMarkdownUtils applyMarkdownFormatting:withDefaultTextAttributes:markdownStyle:parserId:].

The fix

Two changes, both aimed at removing "main thread waits on a runtime-bound lock":

1. MarkdownParser — never hold an ObjC lock across the runtime call.

parse: is split into a cache lookup, an unlocked parseUncached: that enters the runtime, and a locked memo write. The lock now only guards the _prev* ivars, so it is never held while waiting on recursive_mutex and the inversion is gone. Two concurrent misses may parse the same text twice; the runtime serializes them, results are identical, and last-writer-wins on a single-entry memo is safe.

Two new entry points:

  • cachedRangesForText:withParserId: — memo lookup only, never touches the runtime, safe from the measure path on the main thread.
  • warmCacheAsyncForText:withParserId: — schedules a parse on a serial QOS_CLASS_USER_INITIATED queue, guarded by an _asyncParseInFlight flag so warm-ups don't pile up.

2. RCTMarkdownUtils — main thread formats from cache only.

The lock narrows to the _markdownStyle/_parserId writes; formatting then uses the local parameters rather than re-reading the ivars, which preserves the per-call consistency the original single lock was there to provide. On a cache miss:

  • main thread: schedule an async warm-up and return without formatting, so the runtime is never entered from the watchdog-monitored thread.
  • background layout threads: parse synchronously as before. This is now safe because parse: no longer holds any lock the main-thread measure path can block on, and the watchdog only monitors the main thread.

Tradeoffs / things I'd like reviewer input on

Flagging these because they're real and I'd rather they get caught here:

  1. A main-thread cache miss measures unformatted text. Styles that change metrics (h1 font size, pre/code font, blockquote indent, emoji font size) would measure wrong for that pass. In practice the main thread should almost always hit the cache, because text changes are committed and parsed on background threads first — but "almost always" is doing some work in that sentence.

  2. Nothing explicitly re-measures after the async warm-up lands. The comment says a subsequent measure/commit picks up the cached ranges, and that's true whenever something else dirties layout, but there's no invalidation triggered by the warm-up itself. If Fabric has cached the measurement for that shadow node, a wrong height could persist until the text changes again. Should warmCacheAsyncForText: complete by dirtying the shadow node / requesting a new commit? That would make convergence guaranteed rather than incidental.

  3. The memo is single-entry, and now shared with a background writer. Two alternating text values thrash it, and in that state the main thread would rarely hit the cache — falling into case 1 repeatedly. A small LRU (even 2–4 entries) keyed on (text, parserId) would make the main-thread fast path much more reliable. Happy to add it here if you'd prefer.

  4. _asyncParseInFlight can skip the newest text. If a warm-up is already running for stale text, the newer text's warm-up is dropped and relies on a later measure pass observing the miss. Combined with (2), there's a path where it doesn't converge promptly. Coalescing to "latest requested text" instead of a boolean would be tighter.

An alternative worth considering: keep formatting synchronous everywhere and instead make the runtime entry non-blocking, or ensure the parse always happens on commit (before measure) so the measure path is cache-only by construction. That's a larger change; this PR is scoped to stopping the watchdog kills.

Related Issues

Expensify/App#93831

Internal: Sentry APP-EF1 (https://expensify.sentry.io/issues/APP-EF1)

Manual Tests

There is no ObjC test harness in this repo, so this was verified manually. Verified in the Expensify app (HybridApp, iOS) carrying this as a patch-package patch over @expensify/react-native-live-markdown@0.1.334:

  • Markdown formatting in the composer still applies correctly — bold, italic, strikethrough, code, pre, blockquote, headings, mentions, links, emoji.
  • Typing, fast typing, and paste of a large markdown block: formatting applies without a visible unformatted frame.
  • Editing an existing message with markdown: correct formatting on open.
  • Switching between reports with different composer drafts: no stale or cross-contaminated formatting (i.e. no wrong parserId applied).
  • No main-thread hangs on the previously reproducing path.

Reviewers: please pay particular attention to composer height on first render of a heavily formatted draft — that's where tradeoff (1)/(2) above would show up as a layout jump or wrong height.

Linked PRs

@war-in

war-in commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Summary of the PR. Please correct me if I misunderstood something

The main thread will always trigger the markdown parser that parses a text on a separate worklet thread, so the main thread is not blocked. This main thread won't synchronise on parsing, so in some situations the text input height can be outdated and will jump in the next commit (after parsing is finished).

Regarding the questions

  1. That's a real concern. We should make sure to test that thoroughly, so we dont introduce regressions if possible
  2. I'd definitely trigger a rerender if possible. We could use dirty state if necessary
  3. Yes, let's save more than just one cache entry 👍
  4. I think we could start a new parse immediately and remove the stale, still ongoing one, so we're sure the newest text will always appear

@quinthar quinthar added the #quality Relates to work in the #quality room label Aug 11, 2026
@staszekscp

Copy link
Copy Markdown
Contributor Author

Hey! I think the questions from the PR description were covered according to your answers @war-in!

@war-in

war-in commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

That's great! Could we link this PR as a git dependency in your E/App PR, so C+ could jump in and test that solution in the Expensify app? We should make sure we won't introduce any regressions 🙏
cc @staszekscp

@staszekscp
staszekscp marked this pull request as ready for review August 18, 2026 06:58
@staszekscp

Copy link
Copy Markdown
Contributor Author

The changes can be tested via this PR!

@situchan situchan left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From AI analysis:

Comment thread apple/RCTMarkdownUtils.mm
handler();
}
}];
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This early returns before MarkdownFormatter runs whenever the measure pass is on the main thread and the parser cache misses.
Before, parse: was synchronous, so the string handed to setStateData (MarkdownTextInputDecoratorShadowNode.mm:308, :323) was always formatted.
formatAttributedString is what applies h1 font size, code/pre font, blockquote indent and emoji size — so skipping it changes the measured height.
The first measure after an input mounts is a guaranteed miss: markdownUtils_ (and its empty MarkdownParser cache) is created one line earlier at :224.

Repro steps

Example app:

  1. The input is pre-filled with
# header1`, `> blockquote`, ```codeblock```, 😀🍕🍔

Note how tall the input box is and that all of it is visible inside the border.
2. Rotate the device to landscape.
3. Rotate back to portrait.
Expected: the box stays sized to the formatted text through both rotations.
Actual: on the rotation the box is briefly sized to plain-text metrics — the header line and code block lose their extra height, so the bottom lines sit outside or flush against the grey border before it snaps back.

E/App:

  1. On iPad, open a chat and paste into the composer:
# heading
```code```
😀😀😀

  1. Put the app in Split View and drag the divider, or rotate the iPad.
  2. The composer collapses toward single-line height for a frame before correcting.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

main

Simulator.Screen.Recording.-.iPhone.17.Pro.-.2026-08-20.at.10.21.55.mov

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch

Simulator.Screen.Recording.-.iPhone.17.Pro.-.2026-08-20.at.10.31.49.mov

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apparently the behaviour is exactly the same and there is no glitch

Comment on lines +117 to +126
// Dirty both nodes explicitly instead of relying on
// YGNodeMarkDirty()'s upward propagation: the child is usually already dirty
// from completeClone(), in which case propagation short-circuits and the
// decorator would stay clean. Ancestors then pick the dirty flag up on their
// own, because they are cloned with new children and updateYogaChildren()
// propagates child dirtiness upwards.
if (needsRemeasure_ != nullptr && needsRemeasure_->exchange(false)) {
yogaNode->setDirty(true);
yogaNode_.setDirty(true);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The wrong height stays wrong instead of snapping back

Repro step: (example app)

  1. Tap the input and start typing continuously into the middle of the text (each keystroke is a commit, which is what makes the recovery commit lose the revision race).
    2.. While still typing, rotate the device to landscape.
  2. Stop typing and look at the box.
  3. If it recovered, tap Blur, rotate back, and repeat from step 1. It typically takes a handful of tries.

Expected: box returns to formatted height once rotation settles.
Actual: the box stays at the plain-text height with the last lines clipped, and stays there indefinitely. It only corrects on the next keystroke (which changes the text and forces a fresh measure). Blur/focus, scrolling, and further rotation do not fix it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't see any regression:

Simulator.Screen.Recording.-.iPhone.17.Pro.-.2026-08-20.at.10.33.46.mov

@staszekscp

Copy link
Copy Markdown
Contributor Author

I've verified the AI comments and couldn't see any regression that was pointed out as potential

@situchan

Copy link
Copy Markdown

Reviewer Checklist

  • I have verified the author checklist is complete (all boxes are checked off).
  • I verified the correct issue is linked in the ### Fixed Issues section above
  • I verified testing steps are clear and they cover the changes made in this PR
    • I verified the steps for local testing are in the Tests section
    • I verified the steps for Staging and/or Production testing are in the QA steps section
    • I verified the steps cover any possible failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
  • I checked that screenshots or videos are included for tests on all platforms
  • I included screenshots or videos for tests on all platforms
  • I verified that the composer does not automatically focus or open the keyboard on mobile unless explicitly intended. This includes checking that returning the app from the background does not unexpectedly open the keyboard.
  • I verified tests pass on all platforms & I tested again on:
    • Android: HybridApp
    • Android: mWeb Chrome
    • iOS: HybridApp
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • If there are any errors in the console that are unrelated to this PR, I either fixed them (preferred) or linked to where I reported them in Slack
  • I verified proper code patterns were followed (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick).
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I verified that this PR follows the guidelines as stated in the Review Guidelines
  • I verified other components that can be impacted by these changes have been tested, and I retested again (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar have been tested & I retested again)
  • If a new component is created I verified that:
    • A similar component doesn't exist in the codebase
    • All props are defined accurately and each prop has a /** comment above it */
    • The file is named correctly
    • The component has a clear name that is non-ambiguous and the purpose of the component can be inferred from the name alone
    • The only data being stored in the state is data necessary for rendering and nothing else
    • For Class Components, any internal methods passed to components event handlers are bound to this properly so there are no scoping issues (i.e. for onClick={this.submit} the method this.submit should be bound to this in the constructor)
    • Any internal methods bound to this are necessary to be bound (i.e. avoid this.submit = this.submit.bind(this); if this.submit is never passed to a component event handler like onClick)
    • All JSX used for rendering exists in the render method
    • The component has the minimum amount of code necessary for its purpose, and it is broken down into smaller components in order to separate concerns and functions
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG)
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • For any bug fix or new feature in this PR, I verified that sufficient unit tests are included to prevent regressions in this flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.
  • I have checked off every checkbox in the PR reviewer checklist, including those that don't apply to this PR.

Screenshots/Videos

Android: HybridApp
Android: mWeb Chrome
iOS: HybridApp
ios1.mov
ios2.mov
iOS: mWeb Safari
MacOS: Chrome / Safari

Comment thread apple/MarkdownParser.h
// the calling thread. Used when the main thread needs ranges during layout but
// must not wait on the worklet runtime (see Sentry APP-EF1).
//
// Requests are coalesced latest-wins: the most recent (text, parserId) always

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's update and simplify all the comments to be more human readable.
i.e. coalesced

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

Labels

#quality Relates to work in the #quality room

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants