Skip to content

fix(iOS): main-thread deadlock in markdown measurement under locked ShadowTree commits#582

Open
eszlamczyk wants to merge 2 commits into
mainfrom
fix/550/ios-device-deadlock
Open

fix(iOS): main-thread deadlock in markdown measurement under locked ShadowTree commits#582
eszlamczyk wants to merge 2 commits into
mainfrom
fix/550/ios-device-deadlock

Conversation

@eszlamczyk

@eszlamczyk eszlamczyk commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

What/Why?

Fixes #550 - an iOS, physical-device-only deadlock that froze the app and got it killed by the watchdog (0x8BADF00D); the reporter's production app logged ~19 such kills per day.

The mechanism

(confirmed from symbolicated production .ips reports and an instrumented RN-from-source build):

  1. measureContent used dispatch_sync to the main thread - on every measure for the Dynamic Type font scale, on streaming ticks for view.bounds, and on cache misses to render/measure through a throwaway mock view.
  2. RN's experimental preventShadowTreeCommitExhaustion flag (releaseLevel: experimental) re-runs an entire commit - layout included - while holding revisionMutexRecursive_ after 3 failed optimistic commits.
  3. Reanimated (with DISABLE_COMMIT_PAUSING_MECHANISM) commits from the main thread every frame: it both causes those consecutive commit failures during slow measured layouts and is the thread that then blocks on the mutex.
  4. Once the JS thread measures under the locked commit and main is parked on the mutex, the next dispatch_sync(main) completes the cycle; JS waits for main, main waits for the mutex JS holds. Frozen forever.

The old code documented its own assumption (safe because RN never synchronously joins the layout queue from main) - RN's flag broke that assumption without breaking any contract. The only robust fix is structural: nothing in the
measurement path may wait on the main thread, ever
(Yoga's measureContent makes no promises about calling context or held locks).

The fix

Measurement is now main-thread-free by construction, mirroring RN core's RCTTextLayoutManager and this library's own Android MeasurementStore:

  • Font scale: read from LayoutContext::fontSizeMultiplier (the parameter RN already delivers into measureContent, same source ParagraphShadowNode uses; RN refreshes it and re-runs layout on Dynamic Type changes). Pixel rounding uses LayoutContext::pointScaleFactor - RCTScreenScale() dispatch_syncs to main on first use.
  • Streaming size: the view publishes its last committed size into a lock-free packed std::atomic<uint64_t> mailbox (ENRMAtomicSize) from updateLayoutMetrics:; the measure path reads it wait-free instead of syncing view.bounds.
  • View-free measurement (ENRMViewFreeMeasurement.h): parse -> the SAME renderer the visible view uses (ENRMRenderASTNodes / ENRMRenderSegmentsFromAST, so measured layout ≡ rendered layout) -> a fresh per-call NSTextStorage/NSLayoutManager/NSTextContainer stack on the calling thread (TextKit is thread-confined, not main-only - the RCTTextLayoutManager / AsyncDisplayKit pattern). The segmented EnrichedMarkdown view gets static, view-free table and math measurers (+[TableContainerView measureHeightForTableNode:…], +[ENRMMathContainerView measureHeightForLatex:…]) extracted from the same code the views run, so heights cannot drift. Mock-view measurement is deleted.
  • @autoreleasepool per measure: the old main-thread path silently relied on the main runloop draining autoreleased objects; the JS/layout thread has no such drain, and without explicit pools sustained re-measurement accumulates until jetsam (caught during device verification).
  • RaTeX concurrency (math now parses on the calling thread, possibly concurrently with the render thread) was audited against RaTeX sources - stateless engine, NSLock-guarded idempotent font registration, thread_local FFI error slot, immutable OnceLock/LazyLock statics, per-call renderer caches.
Independent bonus

The dispatch_sync design also serialized all markdown measurement onto the main thread (30–80 ms per node on device), visibly stuttering animations and scroll during streaming - the second complaint in #550. Measurement now runs off main entirely.

Known limitation

EnrichedMarkdownTextInput's measureContent still measures live editable UITextView state through dispatch_sync and needs its own strategy - tracked separately. The threading contract in ShadowMeasurementUtils.h documents it as the one remaining violation.

Testing

  • Positive control (old library, dedicated repro app) aggressive harness -MAX_COMMIT_ATTEMPTS_BEFORE_LOCKING = 1 in instrumented RN, width jitter forcing a full re-measure of every card every 900 ms, 8 streaming heads at 16 ms. Deadlocked within seconds, reproducibly. Paused-debugger stacks and the watchdog .ips (0x8BADF00D, EXC_CRASH SIGKILL) match the 20 production reports frame for frame:
    • JS thread: __ulock_wait -> _dispatch_sync_f_slow -> ENRMFontScaleForMeasurement -> ENRMMeasureMarkdownContent -> measureContent -> yoga -> ShadowTree::tryCommit -> ShadowTree::commit (locked fallback)
    • Main thread: __psynch_mutexwait → recursive_mutex::lock → ShadowTree::tryCommit → reanimated::…::commitUpdates
  • Fix verification (this branch, identical harness, same iPhone 13 mini) - 20+ minutes, on the order of tens of thousands of locked-fallback engagements, every LOCKED FALLBACK ENGAGED paired with DONE, zero sync probes (nothing left to probe), no freeze, no watchdog, no jetsam. Memory flat (~415 MB footprint on simulator over multi-minute profiling; CoreAnimation/malloc categories attributed with footprint/heap).
  • Example app on simulator — Text screen (headings, images, nested lists, RTL/mixed-direction paragraphs, blockquote) renders end-to-end with no clipping/gaps through the final element; Stream screen (streaming tables + block LaTeX) keeps stable heights mid-stream and at completion. Did not find any regression (via maestro nor via manuall tests
  • Library pod and example app build clean for simulator and device.

PR Checklist

  • Code compiles and runs on iOS
  • Code compiles and runs on Android
  • Updated documentation/README if applicable
  • Ran example app to verify changes
  • E2E tests are passing
  • Required E2E tests have been added (if applicable)

@eszlamczyk

Copy link
Copy Markdown
Collaborator Author

Confirmed via maestro tests - no regression

Copilot AI 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.

Pull request overview

Eliminates an iOS main-thread deadlock risk in Fabric/Yoga measureContent by making markdown measurement main-thread-free and view-free, aligning the measurement pipeline with React Native’s off-main text measurement approach.

Changes:

  • Replaces dispatch_sync(main) measurement with view-free TextKit-based measurement for EnrichedMarkdownText and segmented EnrichedMarkdown, driven by LayoutContext (font scale + point scale factor).
  • Adds a lock-free ENRMAtomicSize mailbox so streaming measurement can reuse the last committed size without reading view.bounds or blocking.
  • Extracts view-free table and math block measurers (+measureHeightForTableNode:..., +measureHeightForLatex:...) to keep rendered and measured heights aligned without instantiating views.

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
packages/react-native-enriched-markdown/ios/views/TableContainerView.m Refactors table cell rendering/layout into shared statics and adds view-free table height measurement.
packages/react-native-enriched-markdown/ios/views/TableContainerView.h Exposes a view-free table measurement API for shadow-node measurement.
packages/react-native-enriched-markdown/ios/views/ENRMMathContainerView.m Adds a view-free math block height measurement method mirroring the view path.
packages/react-native-enriched-markdown/ios/views/ENRMMathContainerView.h Documents/exposes view-free math measurement API for shadow-node measurement.
packages/react-native-enriched-markdown/ios/utils/ENRMTextViewSetup.h Extracts shared “finalize measured size” logic to reuse across view-backed and view-free measurement.
packages/react-native-enriched-markdown/ios/utils/ENRMAtomicSize.h Introduces lock-free atomic mailbox used to publish/read last committed layout size cross-thread.
packages/react-native-enriched-markdown/ios/internals/ShadowMeasurementUtils.h Updates measurement contract to forbid main-thread waits; switches font scale source to LayoutContext.
packages/react-native-enriched-markdown/ios/internals/ENRMViewFreeMeasurement.h Implements view-free measurement pipelines for both markdown components (TextKit + segment walkers).
packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownTextShadowNode.mm Switches measureContent to view-free measurement and passes LayoutContext scale factors.
packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownTextShadowNode.h Removes mock-view setup API now that measurement is view-free.
packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownShadowNode.mm Switches segmented markdown measureContent to view-free segment measurement.
packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownShadowNode.h Removes mock-view setup API now that measurement is view-free.
packages/react-native-enriched-markdown/ios/EnrichedMarkdownText.mm Publishes last committed size in updateLayoutMetrics: and exposes it for streaming measurement.
packages/react-native-enriched-markdown/ios/EnrichedMarkdownText.h Declares thread-safe lastCommittedLayoutSize API.
packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm Publishes last committed size in updateLayoutMetrics: and exposes it for streaming measurement.
packages/react-native-enriched-markdown/ios/EnrichedMarkdown.h Declares thread-safe lastCommittedLayoutSize API.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@eszlamczyk
eszlamczyk requested a review from hryhoriiK97 July 23, 2026 07:19
@eszlamczyk
eszlamczyk marked this pull request as ready for review July 23, 2026 07:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

iOS device-only deadlock (watchdog 0x8BADF00D): measureContent dispatch_sync to main vs Reanimated ShadowTree commit

2 participants