fix(android): stop split-bundle extraction racing itself - #108
Merged
Merged
Conversation
huhuanming
force-pushed
the
fix/split-bundle-extract-race
branch
from
September 16, 2026 01:18
9402498 to
8520124
Compare
The main and background runtimes resolve the same segment independently, and extractSemaphore is an I/O throttle rather than mutual exclusion — so both can extract one relativePath at once. They shared a single "<name>.tmp", which means two O_TRUNC writers on the same inode and a renameTo that fails for whichever thread loses. That loser reported SPLIT_BUNDLE_NOT_FOUND for a file that was on disk and complete, and the JS loader caches NOT_FOUND as a permanent failure, so a millisecond-wide race blanked a route for the rest of the process. Observed on the first launch after an APK replace, where the install-stamp wipe empties the extract tree and forces every segment to re-extract at once — widest window on the largest segment. - serialize extraction per path with extractPathLocks, held OUTSIDE the semaphore so a waiter never sits on a permit it isn't using - give each attempt a unique temp name, so concurrent writers can never publish a partially zeroed HBC (holds across processes too), and reclaim that temp file in a finally — unlike the old shared name, a unique one is never reused, so a failed extraction would otherwise orphan its partial write until the next APK replace - re-check the destination when renameTo fails, comparing against the bytes just written rather than re-reading the asset, instead of reporting a segment another writer just published as missing - decide a size mismatch under the per-path lock: deleting outside it could unlink a file another thread had just published Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bump all 40 publishable packages to 3.0.137 and record the Android split-bundle extraction race fix in the changelog. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
huhuanming
force-pushed
the
fix/split-bundle-extract-race
branch
from
September 16, 2026 01:49
8520124 to
52ec9d2
Compare
…ramework The published tarball was 14.9 MB packed / 42.5 MB unpacked — 9x the next largest package in this repo (1.7 MB) and ~100x the median — because the vendored GopenPGP xcframework carries three ~10 MB gomobile static archives, one of them a fat simulator slice holding both arm64 and x86_64. Dropping x86_64 takes it to 30.7 MB unpacked / 10.7 MB packed. The slice directory is renamed to match its contents and the xcframework manifest updated so Xcode still resolves it; the podspec vendors the whole xcframework, so nothing else referenced the old name. Cost: Intel Mac simulator builds are no longer supported by this module. Apple Silicon simulator, device, and Mac Catalyst are unchanged. Symbol stripping is not an alternative here — `strip -S` rejects the gomobile archives with "string table not at the end of the file", a known consequence of Go's object layout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3.0.137 published for 39 of the 40 packages. npm left @onekeyfe/react-native-bundle-crypto@3.0.137 staged but never committed: the tarball 404s, the packument never listed the version, and every republish is rejected with "409 Cannot publish over previously staged version". That version number is unrecoverable, so the whole set moves to 3.0.138 rather than letting one package drift out of lockstep. Carries the bundle-crypto framework slimming (14.9 MB -> 10.7 MB packed), which is the most likely reason npm routed that one package through a staging path the other 39 never touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e retries
Two gaps that turned the 3.0.137 release into a silent partial one.
The workflow took npm's exit code as proof of publication. npm reports
success when it accepts a tarball, which is not the same as the version
becoming available: @onekeyfe/react-native-bundle-crypto@3.0.137 was
staged and never committed, so it was undownloadable AND unrepublishable
("409 Cannot publish over previously staged version"), every step of the
run was green, and the miss was only found later by hand. The new
verify-published step reads each version back from the registry and
fails the run that produced it.
It reads through `?write=true` deliberately. The read-through CDN served
hours-stale package documents during that incident and made three
healthy packages look unpublished, so a naive check would fail good
releases and train people to ignore it.
The second gap: retrying one failed package meant re-running the whole
release, where the 39 already-published ones reject with E403 and the
run goes red before proving anything about the one that mattered. The
`only_workspace` input publishes and verifies just that package.
Verified against the live registry: all 40 packages at 3.0.138 pass,
single-workspace mode passes, an unknown workspace and a missing
dist-tag both exit 1. 13 unit tests cover the pure logic.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
zhaono1
approved these changes
Sep 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
A user hit a blank tab right after upgrading the APK (6.3.0 → 6.5.0). From the attached logs, one segment failed to load and stayed failed for the rest of the process:
The file was on disk and complete the whole time — 21 s later the same path resolved fine.
Root cause
extractBuiltinSegmentIfNeededhad three problems that only line up under concurrency:extractSemaphoreis an I/O throttle, not mutual exclusion. WithMAX_CONCURRENT_EXTRACTS = 2, two threads can extract the samerelativePathat once — and there are exactly two callers, the main runtime and the background runtime, resolving segments independently."<name>.tmp"is fixed per path, so both threads open the same file withO_TRUNCand write into each other's stream. Besides the rename race, that can publish a partially zeroed HBC (which then surfaces asSPLIT_BUNDLE_SHA256_MISMATCHor a Hermes eval failure).null, without checking whether the destination had just been published by the winner. The JS loader cachesSPLIT_BUNDLE_NOT_FOUNDas a permanent failure, so a millisecond-wide race blanked a route until restart.It shows up specifically on the first launch after an APK replace because the
install-stampwipe empties the whole extract tree, forcing every segment to re-extract at once — widest window on the largest segment (4.7 MB here). On any other launch theexists()fast path means the race never opens.iOS is unaffected:
resolveAbsolutePathonly doesfileExistsAtPathagainst the OTA dir andNSBundle.resourcePath, with no extraction, temp file, or rename.Change
extractPathLocks— exactly one thread extracts; the rest wake on theexists()fast path. The lock is held outside the semaphore so a waiter never sits on a permit it isn't using, and the re-check under the lock can hand back an already-extracted file without spending one. No cycle is introduced: nothing takes a path monitor while holding a permit, and the install-stamp gate always completes before either.<name>.<tid>-<nanoTime>.tmp) — no two writers ever share an inode, which holds across processes too, where a static lock cannot. Because a unique temp file is never reused by a later attempt, it is reclaimed in afinally: without that, every failed extraction would orphan its partial write (multi-MB for the large segments) until the next APK replace, where the old shared name left at most one.renameTofails, comparing against the bytes just written rather than re-reading the asset, and requiring an exact match — accepting an unknown size there would have been laxer than the checks above it.exists()+ size fast path still short-circuits without locking; only the mismatch case falls through.Verification
./gradlew :onekeyfe_react-native-split-bundle-loader:compileDebugKotlin— BUILD SUCCESSFUL, with the new code confirmed in the emitted classes.Also in this PR: bundle-crypto framework slimming
@onekeyfe/react-native-bundle-cryptowas 14.9 MB packed / 42.5 MB unpacked — 9x the next largest package here (1.7 MB) and ~100x the median — because the vendoredGopenpgp.xcframeworkcarries three ~10 MB gomobile static archives, one of them a fat simulator slice holding both arm64 and x86_64. Dropping x86_64 takes it to 10.7 MB / 30.7 MB. The slice directory is renamed to match its contents and the xcframework manifest updated; the podspec vendors the whole xcframework, so nothing else referenced the old name.strip -Sis not an alternative — it rejects the gomobile archives with "string table not at the end of the file".The lasting fix is to stop shipping the framework inside the npm tarball and fetch it at
pod installtime via anhttpsource in the podspec; that needs a hosting decision and is tracked separately.Release
Rebased onto
mainand bumped all 40 publishable packages with a changelog entry, so this PR is publishable as-is.Already published: 3.0.138, tagged
latest, all 40 packages verified against the registry origin. Note 3.0.137 is a partial release — it reached 39 packages, but npm left@onekeyfe/react-native-bundle-crypto@3.0.137staged and never committed it, so that version is undownloadable and permanently unpublishable (409 Cannot publish over previously staged version). Both CHANGELOG entries record this.The matching pin bump to 3.0.138 in app-monorepo (
package.json× 4,yarn.lock,apps/mobile/ios/Podfile.lock) is in the linked PR below.Related
The JS half of this fix — giving
SPLIT_BUNDLE_NOT_FOUNDone re-attempt instead of caching it on sight — is in OneKeyHQ/app-monorepo#13461. That one reaches the binaries already in the field, since OTA bundles run on whatever native build is installed; this PR needs a new APK.🤖 Generated with Claude Code