Repository navigation
[Due for payment 2026-09-28] [Sentry: APP-KDN] iOS HybridApp native crash — RCTBlobManager slices a blob past its data length #100843
Description
Sentry
- https://expensify.sentry.io/issues/APP-KDN —
NSRangeException: -[NSConcreteMutableData subdataWithRange:]: range {0, 321} exceeds data length 318
Impact (snapshot at filing, 2026-09-10)
| Sentry ID | Users (last 14d) | Users (total since first seen) | Events | First seen |
|---|---|---|---|---|
| APP-KDN | 5 | 6 | 19 | 2026-08-25 |
- Last seen: 2026-09-10 (ongoing)
- Platform: iOS HybridApp (
com.expensify.expensifylite) - App version(s): 9.4.68-1, 9.4.70-0, 9.4.71-0, 9.4.72-0, 9.4.73-2, 9.4.75-0 — present on every recent build
- Mechanism:
nsexception, levelfatal— this is a hard native crash, not a caught error - Thread: crashes on a
_dispatch_workloop_worker_thread, off the main thread session.uptime_bucket: 0-30son the sampled event, so it fires early in the session- Device on the sampled event: iPhone 12 mini, iOS 16.7,
lowMemoryThreat: true
Error
NSRangeException: *** -[NSConcreteMutableData subdataWithRange:]: range {0, 321} exceeds data length 318
Stack trace (top frames)
__exceptionPreprocess
objc_exception_throw
-[NSData(NSData) subdataWithRange:]
-[RCTBlobManager resolve:offset:size:] (RCTBlobManager.mm:115)
-[RCTBlobManager resolve:] (RCTBlobManager.mm:101)
-[RCTBlobManager createFromParts:withId:] (RCTBlobManager.mm:199)
facebook::react::ObjCTurboModule::performVoidMethodInvocation (RCTTurboModule.mm:461)
_dispatch_call_block_and_release
Suspected cause
RCTBlobManager stores blob data in a dictionary keyed by blob ID and slices it on demand. resolve:offset:size: calls subdataWithRange: using the offset and size recorded in the blob descriptor, without checking them against the length of the data actually held.
The numbers in the message say the descriptor and the stored data disagree by 3 bytes: the JS side asks for 321 bytes but only 318 are present. Two plausible sources:
- The stored
NSMutableDatawas truncated or partially written, so the recorded size is stale. - A byte-length versus character-length mismatch when a string is turned into a blob — a multi-byte UTF-8 sequence counted as characters would produce exactly this kind of small shortfall.
createFromParts:withId: is on the stack, so the crash happens while assembling a multi-part blob, which is what new Blob([...]) and FormData uploads go through. The most likely trigger in App is an attachment or receipt upload.
Because this is an NSRangeException on a background dispatch queue, there is no JS frame to catch it and the app terminates.
Reproduction
Unknown as an exact click path — 19 events across 6 users, so it is timing or content dependent rather than a deterministic screen. Starting points:
- On iOS HybridApp, upload a receipt or attachment shortly after launch, while the app is still hydrating.
- Try content that is not plain ASCII — a file name or text part containing multi-byte UTF-8 characters.
- Watch for the crash under memory pressure; the sampled event had
lowMemoryThreat: true.
Once reproduced, the fix is to clamp the range in resolve:offset:size: against the real data length and log rather than throw when the descriptor disagrees. That belongs upstream in react-native, so the immediate options are a patch file or a version bump once fixed upstream.
Related
- Parent tracker: [Tracking] Top Sentry crashes monthly check-in #91302
Issue Owner
Current Issue Owner: @mallenexpensifyMetadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone
Activity
melvin-bot commentedon Sep 10, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @daledah (
External)melvin-bot commentedon Sep 10, 2026
Unable to auto-create job on Upwork. The BZ team member should create it manually for this issue.
MelvinBot commentedon Sep 10, 2026
Proposal
What is the root cause of that problem?
RCTBlobManagertrusts the blob descriptor that JS sends it and slices the storedNSDatawithout ever range-checking it.RCTBlobManager.mm:104-118guards only against a missing blob. If the blob exists, line 115 callssubdataWithRange:with the JS-suppliedoffset/sizeand no comparison againstdata.length. Here the descriptor said 321 bytes, the dictionary held 318, andNSDataraisedNSRangeException.The descriptor and the stored bytes can legitimately disagree, because nothing reconciles them:
sizeon its own inBlobManager.js:87-97(global.unescape(encodeURI(part)).lengthfor string parts), then hands native the parts separately.createFromParts:withId:and adds no bytes at all when a part fails: a blob part that can't be resolved returnsnilat line 199, and a string whosedataUsingEncoding:NSUTF8StringEncodingreturnsnildoes the same at line 202. Both are silent — no log, no error.So an earlier
new Blob([...])produced a blob recorded as 321 bytes but stored as 318. The crash happens later, when that short blob is used as a part of another blob: the stack frames map exactly to line 199 → line 101 → line 115.This is iOS-only for a concrete reason: Android's
BlobModule.kt:183-198usesArrays.copyOfRange, which zero-pads when the requested end is past the array. Same desync, no crash. And becausecreateFromPartsruns on a_dispatch_workloop_worker_threadwith no JS frame on the stack, theNSRangeExceptionis unrecoverable.Evidence, and what I could not confirm
Confirmed: upstream
mainstill has the unguardedsubdataWithRange:— this is not fixed in a later react-native version, so a version bump will not help.Not confirmed: the exact 3-byte shortfall. Sentry gives 19 events across 6 users with no reproduction path, and the descriptor/data desync is created at blob creation time while the crash surfaces at consumption time, so the crashing stack cannot name the culprit. Candidate sources I considered:
[NSString dataUsingEncoding:]— for example a JS→NSStringconversion that mangles an astral character. A 3-byte gap is the size of one UTF-8 BMP character or one U+FFFD.Blob.jsslice()does not handle a negativestart: it doesoffset += start; size -= start, producing a negative offset and an oversized size. Ruled out for this event — the crash reportsoffset0.I deliberately did not try to pin this down further, because the fix below does not depend on which one it is, and adding a log makes the next occurrence self-diagnosing.
On the App side: nothing in
src/callsnew Blob([blob])on a native path —DownloadUtils.ts:82is reached only through the webindex.ts. Thenew Blob([...])calls are most likely coming fromwhatwg-fetch'sResponse.blob(), which react-native'sfetchuses. That means this cannot be fixed in App JS.What changes do you think we should make in order to solve the problem?
Add a
patch-packagepatch forreact-nativeand send the same change upstream. App already patches this exact module — seereact-native+0.86.0+030+fix-nil-BlobModule-crash-APP-8BM.patch— so this follows an established pattern.Two changes in
RCTBlobManager.mm:resolve:offset:size:, clamp the range to the data that is actually there. Returnnilfor a nonsensical offset, and otherwise slice at mostdata.length - offsetbytes, matching Android's forgiving behaviour. This turns a hard crash into a short read.createFromParts:withId:, and in the clamp above, callRCTLogErrorwhen a part resolves tonilor when the requested size exceeds what is stored, including the blob ID and both lengths. Right now the mismatch is invisible until it kills the app; this is what will let us find the real producer.Add the matching entry to
patches/react-native/details.mdand link the upstream PR there.What alternative solutions did you explore? (Optional)
mainis still unguarded.BlobManager.js. Would address one possible source, but native would still crash on every other desync, and we do not yet know which source this is. The native clamp is the change that actually stops the crash.Related
NSRangeExceptionhit, [$250] [Sentry: APP-7CX] HybridApp iOS Fatal App Hang in YAPLJS callFunction during onBarButtonItemSelected (still occurring after #86334 closed) #91229, is an unrelated app hang.Next Steps for Contributor+ team:
To accept:
@MelvinBot implement [this](https://github.com/Expensify/App/issues/100843)to create a draft PR.To refine:
@MelvinBot <your feedback>To reject: Explain why you are rejecting Melvin's proposal.
view run · view recording
mountiny commentedon Sep 11, 2026
@daledah can you review the proposla from melvin and open this to community if not good enough? thanks
daledah commentedon Sep 12, 2026
I will look into this issue early next week
daledah commentedon Sep 14, 2026
Thanks @MelvinBot. The clamp is the right instinct and I want it in the fix, but the proposal has three factual errors and stops one layer short of the real cause. I reproduced this deterministically on an iOS simulator (RN 0.86.0, New Arch), so we no longer have to guess at the producer.
Root cause
RCTTurboModule.mm:113converts every JSI string argument with:value.utf8(runtime)knows its own length, but.c_str()throws that away, sostringWithUTF8String:re-derives it withstrlenand stops at the first NUL byte. MeanwhileBlobManager.jscounts the whole string. Descriptor and stored bytes then disagree by exactly "the NUL plus everything after it".Measured on device - all cases built to the same JS byte count of 318, so the only variable is what native stored:
A×318A×315 +\0+BCA×317 +\0\0+A×317A×316/315/314 +é/中/😀desync equals the byte count from the NUL onward, exactly. This disproves suspected cause 2 in the issue body - multi-byte UTF-8 round-trips fine, emoji included. A NUL does not, and a NUL followed by two characters gives exactly the 3-byte APP-KDN shortfall.
I read the true stored length without tripping the range check by resolving a
{offset: 0, size: -1}descriptor (which skipssubdataWithRange:entirely) and base64-measuring it viareadAsDataURL.Feeding that 3-byte-short blob into
new File([blob], 'receipt.jpg')then crashes, on queuecom.meta.react.turbomodulemanager.queue:Same frames as APP-KDN.
The fix
Layer 1 - the actual cause:
Preserves embedded NULs and drops a
strlenfrom a hot path. Broad change - every TurboModule string argument on iOS - so it needs a careful upstream PR and a blast-radius note, but truncating user data at a NUL is a bug, not a contract.Layer 2 - defence in depth, so any future desync degrades instead of killing the app:
size < 0preserves the existing "-1means the whole blob" contract. Add a nil guard with a log increateFromParts:withId:too, where a failed part currently contributes 0 bytes with no signal.Use
RCTLogWarn, notRCTLogError- this runs on a background queue andRCTLogErrorred-boxes on every occurrence in dev. For Sentry visibility, route it like patch 038.Trade-off the proposal doesn't state: layer 2 alone converts this crash into silent data loss - with a leading NUL, a 0-byte attachment that uploads as if it succeeded (measured above, not hypothetical). That's why layer 1 is needed.
Still open: what puts a NUL into an App string in production. Best lead is
whatwg-fetch'sResponse.blob()doingnew Blob([this._bodyText])- binary content read as text would carry NULs. Doesn't block the fix.Next steps
Both patches go in
patches/react-native/, with entries indetails.mdlinking the upstream PRs.@MelvinBot implement this
MelvinBot commentedon Sep 14, 2026
Implemented both layers exactly as specified. Draft PR: #101062
What's in it — two patches in
patches/react-native/, both documented indetails.md:react-native+0.86.0+042+fix-jsi-string-truncated-at-nul-APP-KDN.patch—convertJSIStringToNSStringnow builds theNSStringwithinitWithBytes:length:encoding:from the byte length thejsi::Stringalready knows, instead of lettingstringWithUTF8String:re-derive it withstrlenand stop at the first NUL.react-native+0.86.0+043+clamp-blob-range-APP-KDN.patch— your clamp, verbatim, inresolve:offset:size:, withsize < 0preserving the "-1means the whole blob" contract, plus nil guards with logs increateFromParts:withId:for both the unresolvable-blob-part and the unencodable-string-part cases.RCTLogWarnthroughout, and#import <React/RCTLog.h>added sinceRCTBlobManager.mmdidn't pull it in transitively.What I verified
./scripts/validatePatches.shpasses.patch-packageapplies the fullreact-nativesequence 001–043 cleanly against RN 0.86.0, with 042 and 043 applying last. I generated the patches by hand (this environment has no network forpatch-package --append), so I round-tripped them throughgit apply -R/git applyfirst and then confirmed throughpatch-packageitself — the path that actually runs onnpm install.npm run lint-changedandnpm run spell-changedare no-ops here; nothing outsidepatches/changed.I did not run an App test pass. These are iOS native patches — nothing in them is reachable from a web or Android build, so a smoke test there would be evidence about something else. Verification needs an iOS build, and I left the
TestsandQAsections for you.Two things to decide before this ships
RCTLogWarn, as you specified. I did not wire it to Sentry: patch 038's mechanism isReactSoftExceptionLogger, which is Android-only, and I have not confirmed that a nativeRCTLogWarnreaches Sentry on iOS in a production build.consoleIntegrationis enabled insrc/setup/telemetry/setupSentry.ts:37-43, but the native→JS log forwarding path is what I couldn't confirm, so I'd rather flag it than claim it works. If you want guaranteed visibility, that needs a separate call on the mechanism.details.mdentries say so explicitly rather than carrying a 🛑 with no context — patch 042 in particular changes the conversion for every TurboModule string argument on iOS, and the entry carries the blast-radius note for upstream review.Next Steps: Mark #101062 "Ready for review" once you've filled in the
TestsandQAsections, or reply with@MelvinBot <feedback>to change the implementation.10 remaining items
daledah commentedon Sep 22, 2026
BugZero Checklist:
[Contributor] The offending PR and associated issue have been commented on, pointing out the bug it caused and why, so the author and reviewers can learn from the mistake.
Link to the comment on the PR: N/A
[Contributor] If the regression was CRITICAL (e.g. interrupts a core flow) A discussion in #expensify-open-source has been started about whether any other steps should be taken (e.g. updating the PR review checklist) in order to catch this type of bug sooner.
Link to discussion:
[Contributor] If it was decided to create a regression test for the bug, please propose the regression test steps using the template below to ensure the same bug will not reach production again.
Regression Test Proposal
Test:
Do we agree 👍 or 👎
Zapier Logs
Run ID: 00040eee-9df0-aef5-ef75-63d405aed414mountiny commentedon Sep 22, 2026
@MelvinBot is this crash resolved in the versions that included this fix?
MelvinBot commentedon Sep 22, 2026
Short answer: I can't confirm it from Sentry — the Sentry MCP server isn't connected in this run, so I have no access to APP-KDN's event stream. What I can confirm from the code and the release history is that the crash signature is unreachable on any build that carries the fix, and that only production
9.4.90-2and later qualify.Which versions actually have it
7745d4e(#101062) merged 2026-09-18 10:21 UTC. I checked ancestry against every tag since:9.4.90-0-staging9.4.90-29.4.85-0,9.4.88-0,9.4.89-0and every9.4.84-*/9.4.86-0staging build do not contain it. So when you look at APP-KDN, filter onrelease— anything tagged9.4.89-0or below is expected and is not a regression.Why the signature can't recur on 9.4.90-2+
Patch 043 clamps the range before the slice, so
subdataWithRange:can no longer be called with an out-of-range range at all — seereact-native+0.86.0+043+clamp-blob-range-APP-KDN.patch:20-43. An out-of-bounds offset returnsnil; an oversized size truncates to what is stored. That holds regardless of what caused the desync, so a newNSRangeExceptionon this stack would mean the patch didn't reach the build, not that the fix is wrong.Two things that make "zero events" weaker evidence than it looks
9.4.90-2has been in production ~1 day, and iOS users update over days. At the historic rate (19 events / 6 users over 16 days ≈ 1.2/day fleet-wide), a quiet first week is consistent with the fix working and with nobody having updated yet. Judge it at the end of the regression period, on9.4.90-2+ only.whatwg-fetch'sResponse.blob()on binary content read as text). If 042 missed a path, you'd now see a truncated or 0-byte attachment upload instead of a crash. Worth watching attachment/receipt upload failures alongside the crash count, not just the crash count.What to check when you have Sentry open
Query APP-KDN for events where
release≥9.4.90-2andos = iOS. Also grep iOS logs for the new[BlobManager] blob ...; truncatingwarning from patch 043 — that fires whenever a desync still happens, and its presence would mean 042 didn't fully fix the producer even if nothing crashes.I couldn't verify any of this in a running app either — browser control was unavailable in this run, and these are iOS native patches, so a web build wouldn't exercise them regardless. Verification needs an iOS build plus Sentry.
view run
melvin-bot commentedon Sep 22, 2026
Triggered auto assignment to @mallenexpensify (
Awaiting Payment)mountiny commentedon Sep 22, 2026
Seems like we ready for payment here
melvin-bot commentedon Sep 27, 2026
Payment Summary
Resolving PRs:
Fix APP-KDN: preserve NUL bytes in iOS TurboModule strings and clamp blob ranges #101062
Reviewer: @daledah owed $175 via NewDot
BugZero Checklist (@mallenexpensify)
mallenexpensify commentedon Sep 28, 2026
Payment Summary
Contributor+: @daledah due $250 via NewDot