feat(ios): apply binary patch updates - #155
Merged
Merged
Conversation
Binary patch updates need HDiffPatch's patch applier and zstd's decompressor compiled into the native library, so the minimal set of sources for applying a patch is vendored here. Only the apply side is vendored: libHDiffPatch/HPatch plus zstd's common and decompress directories. zstd's amd64 assembly implementation is left out because the applier builds with ZSTD_DISABLE_ASM=1. The upstream directory layout is kept because the HDiffPatch headers include each other by repository path. The tree lives at the repository root rather than under android/ or ios/ because both platforms compile the same sources: iOS through the podspec at the root, Android through externalNativeBuild. One shared copy leaves nothing to drift.
…lier HDiffPatch takes the decompressor as a plugin. Upstream ships a demo header implementing a dozen codecs, which would pull in headers for codecs the applier never sees, so this is a standalone hpatch_TDecompress implementation for zstd - the only codec CodePush patches are compressed with. The window it accepts is capped at the 2^24 the generation options use, so a corrupted frame header asking for a wider window is rejected before anything is allocated. apply_patch_host is the reference applier built from those sources for the development machine. It loads the base bundle and the patch into memory and writes the restored bundle sequentially, which is the memory contract the Android and iOS appliers follow, and it maps each failure to its own exit code so a caller can tell a corrupt patch from a mismatched base.
Adds the CLI side of the codec - generatePatch and applyPatch spawn hdiffz and hpatchz with the fixed options that define the patch format - together with a test suite that pins the format down against real bytes and real binaries. The fixtures are committed and can be regenerated deterministically by scripts/binary-patch/generate-fixtures.mjs, so the tests verify the patch bytes that ship rather than bytes produced on the fly. hdiffz and hpatchz are built from upstream sources by scripts/binary-patch/build-hdiffpatch.sh, which the suite runs on demand when the tools are missing. Two properties are pinned down because callers have to handle them: a patch carries no checksum of the base data, and its zstd streams carry no content checksums. Applying a patch to a different base of the same size, or applying a patch whose body is corrupted, can therefore report success and still produce the wrong bytes. Verifying the base and target hashes stays the caller's responsibility. The applier sources ship in the npm package so both platforms can build them, minus the host harness, which only exists to run this suite.
An absolute --output-path produced an absolute bundle directory, which the './' prefix turned into a path below the current working directory.
`bundle` and `release` accept --binary-bundle-path, the JS bundle of the target binary. With it, `release` publishes two artifacts per platform: the full bundle named after its packageHash, and `<packageHash>-patch.zip`, which carries the target bundle only as a patch against the binary's bundle plus a `codepush-binary-patch.json` manifest. Every other file is copied unchanged, so applying the patch and dropping the two patch-only files reproduces the full contents byte for byte - and therefore the same packageHash. The Hermes compilation is aligned with the base bundle through `-base-bytecode` when the app's own compiler advertises the flag, which is what keeps the patch small. A compiler without the flag only warns; a compilation that fails with it fails the release, since the base is then wrong. Both archive sizes and the saving are printed before anything is uploaded, and the full bundle is uploaded before the patch. A failed upload of either leaves the release history untouched. Carrying the patch URL in the release history is deliberately not part of this change.
The release action read `options.bundleName`, but commander stores the `-j, --js-bundle-name` flag as `options.jsBundleName`, so a custom JS bundle name never reached `release()` and the platform default was always used. Optionality is now expressed in the types instead of asserted away, so "not given" cannot pass for a name again.
The suites that generate real patches each built the tools in their own `beforeAll`, so a suite without that hook - the release flow suite - failed on a machine with no `.hdiffpatch-tools` yet, and two workers could run the same build at the same time. A jest global setup builds them once before any worker starts, which also removes the duplicated helper from the two suites that had one.
A patch is only worth publishing when it is smaller than the archive it replaces, and the CLI runs unattended in CI, so what happens otherwise is decided up front instead of being left to whoever reads the summary. `skip`, the default, warns, records the skip in the summary and releases the full bundle alone. `fail` stops the release before either artifact is uploaded, so the release history stays untouched. Equal sizes count as oversized: a patch that saves nothing still costs a client an extra download and an apply step.
The Babel config of this repository has no JSX transform - an app bundling the library transforms it with the React Native preset - so a test cannot load src/CodePush.js. Compile that one file with TypeScript instead, which turns the JSX of the decorator into React.createElement calls and leaves the rest of the module to the same downlevelling Babel would have applied.
A release published with --binary-bundle-path uploads a patch archive whose URL was logged and then dropped. Record it in the release history entry of that release, and carry it from the fetched history through the update check to the metadata the native module is handed when it downloads the update. A release without a patch says nothing about one: the field is absent from the history entry and from the update, so a release history written before binary patches existed keeps behaving exactly as it did.
A release published with a binary patch offers two archives of the same update: the full one, and a patch of the JS bundle against the bundle inside the app binary. Download the patch when the release has one, apply it to the binary's bundle and put the restored bundle where the archive left a patch, which leaves contents identical to the full archive - and therefore the folder hash check that follows unchanged. Nothing about a patch is trusted. The manifest is checked before anything is read, the paths it points at have to stay inside the archive, the base bundle is hashed before the patch is applied and the restored bundle is hashed afterwards, and the restored bytes only reach the update contents once both hashes match. A patch carries no checksum of what it produces, so those two hashes are the only thing standing between a corrupted patch and a broken app. Any failure along that path downloads the full archive instead, and does so exactly once: the fallback download is not allowed to take the patch path, so it has no patch failure of its own to fall back from and needs no counter or stored state to say so. Each failure is logged with the reason it fell back, which is what a rollout is judged from. The applier itself is the shared C code the host build compiles too, reached from CMake where it lives rather than copied in.
The android client now installs an update from its patch archive and falls back to the full one, so say so where patch bundles are documented, and record what that adds to a consumer's android build: the NDK and CMake, which compile the applier. The note about installing updates against the binary's bundle being something android cannot do is no longer true, so it now explains why the update check still does not carry the binary's hash.
Three ways an update could go wrong on the patch path, found by testing the download and install steps together instead of apart: An archive wraps its files in a single directory, and a manifest's paths are relative to that directory, so the manifest was looked for one level above where it is. Every patch update would have fallen back to the full archive. A patch URL that answers with something other than an archive - an error page served with a 200, say - took the branch that treats a download as a bare JS bundle and moves it into the package folder under the update's hash, with no patch applied and no hash ever checked, and then reported success so nothing fell back. The patch path now refuses anything that is not an archive. Applying a patch is the one path that holds a whole bundle in memory, and an OutOfMemoryError there escaped the fallback, the native module's error handling and the download task, leaving the promise unsettled - the worst answer to the failure the fallback exists for. It is now absorbed like any other patch failure, and the bundle is read into a buffer sized from the asset instead of one that grows into a second copy of itself. The download and install steps are now covered together, over a real socket with real archives, with only the applier's two seams stubbed.
An update published with a binary patch offers two archives of the same contents, and iOS now installs from the smaller one: the patch archive is downloaded first, the JS bundle it carries only a patch of is rebuilt against the bundle inside the app binary, and what is left is byte for byte what the full archive would have delivered - so the folder hash check that follows the install is unchanged and stays the last line of defence. Nothing about a patch archive is trusted. Its manifest is read before anything is applied and refused when it is not the format this client can apply, when its paths reach outside the archive, or when it promises a bundle larger than a bundle can be. Neither the diff format nor the zstd streams inside it carry a checksum of the data they produce, so the base bundle is hashed before the patch is applied and the restored bundle afterwards, and the restored bytes only reach the update contents once both checks have passed. Every failure downloads the full archive instead, exactly once and with nothing stored to count it: the full archive is fetched by a call that cannot take the patch path, so it has no failure of its own to fall back from. A patch URL that answers with something other than an archive is one of those failures rather than a bare JS bundle to move into place, which would install bytes no hash was ever checked against. The applier is the C code the other platform and the host build compile as well, referenced where it lives rather than copied here, and the pod builds it - which is what keeps the appliers of the platforms from drifting apart.
The iOS client installs an update from its patch archive too, so the paragraph about what a released patch means for a client is no longer about one platform, and the pod section says what that adds to a consumer's iOS build: nothing. CocoaPods compiles the applier along with the rest of the pod, where Android needs the NDK and CMake for the same sources.
Both NSData objects have their last ARC use before the C call that reads their bytes for the whole of its run, so ARC was free to release them - unmapping the base bundle out from under the applier. Give both locals a precise lifetime.
HDiffPatch is pinned to v5.1.3 but its zstd dependency was cloned from the fork's default branch, so any upstream push silently changed the generator and could break a build that had been green. Fetch the pinned commit instead, and record it next to the zstd entry in the third party notices.
`hdiffz` is the one prerequisite a binary patch release has that npm install does not provide, and the error a missing tool raises names `scripts/binary-patch/build-hdiffpatch.sh` - a path the published package did not carry. Add the script to `files`, and document the one-time build, what it needs, where it installs, and the `HDIFFPATCH_TOOLS_DIR` override in both release guides.
floydkim
marked this pull request as ready for review
August 17, 2026 13:06
A manifest is untrusted input, and the size it names is what the restore reserves before reading a byte of the patch. The old 512 MiB ceiling was far above anything a release can produce: a large Hermes bundle stays under 50 MB. A manifest that does exceed the bound costs nothing beyond the full archive being downloaded instead, so 128 MiB leaves ample room while cutting what a bad manifest can ask for.
Requirements lists what an app has to have in place, and by its own wording that bullet asked for nothing: the Android Gradle Plugin installs the NDK and CMake versions it is missing. The binary patch section of the CLI README still says the library builds native code and what that needs, which is where the note belongs.
A manifest is untrusted input, and the size it names is what the restore reserves before reading a byte of the patch. The old 512 MiB ceiling was far above anything a release can produce: a large Hermes bundle stays under 50 MB. A manifest that does exceed the bound costs nothing beyond the full archive being downloaded instead, so 128 MiB leaves ample room while cutting what a bad manifest can ask for. The Android applier moves to the same value in the same change; the two bounds are one contract and have to answer an oversized manifest alike.
…y-patch-cli-artifacts
…h-android-apply # Conflicts: # cli/README.ko.md # cli/README.md
…h-ios-apply # Conflicts: # cli/README.ko.md # cli/README.md
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.
Summary
Fifth PR of the binary differential OTA series (stacked on
feat(android): apply binary patch updates— merge that first).This PR gives iOS the same capability the previous PR gave Android: when
binaryPatchDownloadUrlis present in the update metadata, the client downloads the patch archive, applies the patch against the bundle embedded in the app binary, verifies the result at every step, and installs the reconstructed contents. Any failure at any patch stage falls back to the full archive exactly once, structurally: the full-path invocation cannot reach the patch branch by construction — no counters, no persisted state. A package without the field takes the untouched legacy path.The implementation is a deliberate line-for-line counterpart of the Android applier, because the two platforms share only contracts, not artifacts:
algorithm/formatVersion→ path confinement inside the archive → size bound → available disk space → embedded-base read +baseBundleHash→ apply (with the patch header's compression type and old/new sizes cross-checked against the manifest before the patch runs) → reconstructed-bundle size +targetBundleHash→ move into place → existing folder-hash verification. The post-hash is the only defense against corrupted patch bodies (the compressed stream carries no content checksum), so it is never skipped, and unverified bytes never reach the final package folder.base_hash_mismatch,invalid_manifest,unsupported_format,patch_apply_failed,target_verification_failed,base_bundle_unavailable), logged through the existing channel with apply duration on success — the same staging-validation signal as Android.packageHash; the manifest is resolved inside it, not at the unzip root.invalid_manifestand falls back — it cannot flow into the pre-existing raw-bundle path.Changes
ios/CodePush/CodePushBinaryPatch.{h,m}— the validation/apply pipeline as a self-contained unit, mirroring the Android class of the same name. The C sources are called directly (no bridge layer): the samegetCompressedDiffInfo→ cross-check → sequential-write apply sequence as the desktop harness and the Android JNI wrapper, with the same result codes mapped to the same reasons. The base bundle is memory-mapped (NSDataReadingMappedIfSafe) rather than copied, and all reads use error-returning APIs so allocation or I/O failures become normal fallbacks instead of crashes. C headers are confined to the implementation file — the public header is pure Foundation.ios/CodePush/CodePushPackage.m— exactly two branch points: download-URL selection at the entry of the download flow, and patch application right after the SSZipArchive unzip, beside the existing diff-manifest detection; downstreamcopyEntriesInFolder→ folder-hash flow is untouched. The full path is invoked with the patch branch disabled, which is what makes the single fallback structural. Temp files live under<CodePushPath>/binary-patch/and are cleaned on success, fallback, and failure, including stale leftovers from a killed process.Threading — base hash, apply, and target hash all run on the
_methodQueuethatCodePush.mmalready hands toCodePushPackage(inside the download completion callback). No new queues or threads; nothing runs on the main queue.CodePush.podspec— compiles the shared C sources atcpp/binarypatch/(HDiffPatch apply path, zstd decompress set, and the project's zstd adapter) as an explicit file list, which structurally excludes the host harness and all assembly files (ZSTD_DISABLE_ASM=1,_IS_USED_MULTITHREAD=0). Vendor header roots go throughHEADER_SEARCH_PATHSwith the headers aspreserve_paths, so ~40 vendor headers don't flatten into the pod's header directory where they could collide with other pods. The New Architecture branch overwritespod_target_xcconfigwholesale, so the vendor settings are merged into both branches — easy to miss and verified in both. Existing settings are only appended to. No extra requirement lands on consumers; CocoaPods handles everything.Docs — README section for the iOS integration; the CLI READMEs no longer describe patch support as Android-only.
Lifetime hardening — the memory-mapped base bundle and the patch
NSDataare annotatedobjc_precise_lifetime, so ARC cannot release (and unmap) them while the C apply call is still reading their bytes.Note for JS consumers
A release that falls back downloads two archives, so download-progress can report
receivedBytes == totalBytestwice (once for the patch archive, then again for the full archive, restarting from zero). Promise semantics are unchanged. Android has the same shape.Measurements
25 MB-class bytecode pair (base 22.5 MB / target 22.8 MB, patch 212 KB, target aligned with
-base-bytecode), applied through the same shared C sources this PR compiles:On Android the transient total is ≈ +33 MB (the base bundle as a Java-heap
byte[]at 22.5 MB plus ~4 MB of native decoder cache and buffers), released when the install completes. Apply time is negligible next to download time.* Simulator numbers are host-CPU approximations; a physical-device iOS measurement is an open follow-up.
Test plan
Pod-level verification (both architecture branches):
RCT_NEW_ARCH_ENABLED=1 pod install+xcodebuildof the CodePush pod target on the RN 0.84 example: BUILD SUCCEEDED, all 12 shared C sources compiled,libCodePush.acarries the hpatch/zstd symbols, no warnings from new files.use_react_native!, so the flag cannot reach the old branch on the RN 0.84 example by design. Verified instead with a realRCT_NEW_ARCH_ENABLED=0 pod install+ build on the RN 0.81.6 example (the newest one that honors the flag), plus a direct evaluation of both podspec branches confirming the vendor settings in each.On-device end-to-end scenarios (real patch bytes, fallback drills on both platforms) land with the E2E PR later in the series.