feat(android): apply binary patch updates - #154
Draft
floyd-soomgo wants to merge 3 commits into
Draft
Conversation
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.
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
Fourth PR of the binary differential OTA series (stacked on
feat: add optional binary patch metadata— merge that first).The previous PR delivered
binaryPatchDownloadUrlto native download metadata without consuming it. This PR makes Android consume it: when the field is present, the client downloads the patch archive, applies the patch against the bundle embedded in the app binary, and installs the reconstructed contents — verifying them at every step. Any failure at any patch stage falls back to the existing full archive exactly once, and the fallback is structural (the full path re-enters through an internal call that cannot reach the patch branch — no counters, no persisted state). iOS is the next PR.A package without the field takes the untouched legacy path; the existing full zip, non-zip, and
hotcodepush.jsondiff flows are unchanged downstream.Trust model: the patch archive is treated as unverified input until proven otherwise.
algorithm/formatVersion, and path confinement (no entry may escape the archive) are checked first, then a size bound and available disk space.baseBundleHashbefore anything is applied.targetBundleSizeandtargetBundleHashbefore it is moved into place — this post-hash is the only defense against corrupted patch bodies (the compressed stream carries no content checksum), so it is never skipped. The existing folder-hash verification then confirms the finalpackageHash, same as a full download.base_hash_mismatch,invalid_manifest,unsupported_format,patch_apply_failed,target_verification_failed,base_bundle_unavailable) through the existing CodePush log channel, and a success logs the apply duration — the signal for validating on a staging identifier before enabling the URL in production. Temp files live under<CodePushPath>/binary-patch/and are cleaned on success, fallback, and failure, including stale leftovers from a killed process.Changes
android/app/src/main/cpp/) — a single JNI entry point (HDiffPatchNative.applyPatch) plus a CMake target that compiles the shared C sources atcpp/binarypatch/(HDiffPatch apply path, zstd decompress set, and the project's zstd adapter) by relative path — nothing is re-vendored underandroid/.ZSTD_DISABLE_ASM=1,_IS_USED_MULTITHREAD=0, no assembly files. The wrapper validates the patch header against the caller's expectations, applies with sequential writes to a temp file, and returns distinct error codes that Java maps to fallback reasons.CodePushUpdateManager— exactly two branch points: download-URL selection at the top ofdownloadPackage, and patch application right after unzip, beside the existing diff-manifest detection. The manager now takes the applicationContext(single construction site) to read the embedded bundle viaAssetManagerstraight into memory — no disk copy. A patch download that turns out not to be a zip archive (e.g. an error page served with HTTP 200) is rejected asinvalid_manifestand falls back, instead of flowing into the raw-bundle path.OutOfMemoryErroron the patch path — the only path holding a whole bundle in memory — is caught and treated as a patch failure so the streaming full download still runs; the base-bundle buffer is pre-sized to cut the allocation peak.CodePushBinaryPatch/BinaryPatchResult— the validation pipeline above as a self-contained, JVM-testable unit behind two seams (base-bundle provider, patch applier). Archive contents are resolved the same way the CLI lays them out (everything nested under a single root directory whose name is part ofpackageHash) — the integration tests caught that reading the manifest at the unzip root would have quietly sent every patch to the fallback.externalNativeBuild/CMake wiring in the library module. Consumer builds now need NDK + CMake, which React Native apps generally already satisfy; documented in the README.CodePushBinaryPatchTestdrives real files, real zips, and real SHA-256 (expected hashes computed independently of the production hash code);CodePushUpdateManagerDownloadTestgoes end-to-end through a real loopback HTTP server, the real download/unzip/restore/folder-hash pipeline, with only the two native seams stubbed — covering patch install, non-archive responses, out-of-memory fallback, and request-order assertions ([patch, full]). A package-private constructor exists solely to inject the seams.src/CodePush.jsclaiming Android cannot install binary-based updates now describes the actual behavior.Test plan
Mutation-checked: reverting the target-hash check, the fallback branch, or the fix commit's guards fails exactly the tests that cover them. On-device end-to-end scenarios (real patch bytes on a real archive) land with the E2E PR later in the series.