Skip to content

feat(ios): apply binary patch updates - #155

Merged
floyd-soomgo merged 28 commits into
masterfrom
feature/binary-patch-ios-apply
Aug 19, 2026
Merged

feat(ios): apply binary patch updates#155
floyd-soomgo merged 28 commits into
masterfrom
feature/binary-patch-ios-apply

Conversation

@floyd-soomgo

@floyd-soomgo floyd-soomgo commented Aug 10, 2026

Copy link
Copy Markdown
Member

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 binaryPatchDownloadUrl is 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:

  • Identical validation order — manifest presence/schema/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.
  • Identical fallback reason strings (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.
  • The same archive-layout rule: CLI archives nest all contents under a single root directory that participates in packageHash; the manifest is resolved inside it, not at the unzip root.
  • A patch download that turns out not to be a zip archive (e.g. an error page served with HTTP 200) is rejected as invalid_manifest and 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 same getCompressedDiffInfo → 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; downstream copyEntriesInFolder → 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 _methodQueue that CodePush.mm already hands to CodePushPackage (inside the download completion callback). No new queues or threads; nothing runs on the main queue.

  • CodePush.podspec — compiles the shared C sources at cpp/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 through HEADER_SEARCH_PATHS with the headers as preserve_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 overwrites pod_target_xcconfig wholesale, 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 NSData are annotated objc_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 == totalBytes twice (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:

Android (Galaxy S23, physical device) iOS (iPhone 17 Pro, simulator*)
Patch apply 38 ms 24.1 ms*
Restored-bundle SHA-256 verify 44 ms 20.4 ms*
Peak memory delta native heap +10.1 MB phys_footprint +6.9 MB*

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

npm run typecheck
npm run jest

Pod-level verification (both architecture branches):

  • New ArchitectureRCT_NEW_ARCH_ENABLED=1 pod install + xcodebuild of the CodePush pod target on the RN 0.84 example: BUILD SUCCEEDED, all 12 shared C sources compiled, libCodePush.a carries the hpatch/zstd symbols, no warnings from new files.
  • Old Architecture — RN ≥ 0.82 forces the New Architecture flag inside use_react_native!, so the flag cannot reach the old branch on the RN 0.84 example by design. Verified instead with a real RCT_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.

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
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.
…h-android-apply

# Conflicts:
#	cli/README.ko.md
#	cli/README.md
Base automatically changed from feature/binary-patch-android-apply to master August 19, 2026 07:12
…h-ios-apply

# Conflicts:
#	cli/README.ko.md
#	cli/README.md
@floyd-soomgo
floyd-soomgo merged commit 8c7f1c8 into master Aug 19, 2026
1 check passed
@floyd-soomgo
floyd-soomgo deleted the feature/binary-patch-ios-apply branch August 19, 2026 07:14
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.

1 participant