Mobile Computing · Mobile Release
Install Sessions: Atomicity Across Multiple Split APKs
How the installer stages a set of APKs as one transaction, what commit guarantees about visibility, and what happens to a half-written session when the device reboots.
An app installed from a bundle is several APKs that only work together. The base holds the manifest, the code, and the default resources; the splits hold ABI-specific libraries, density-specific drawables, and per-language strings, with resource identifiers allocated across the whole set. Replacing one without the others produces an app whose parts disagree, and the package manager cannot express “this is inconsistent” as a recoverable state at runtime — it has no way to answer a resource lookup that falls between two tables.
Install sessions are the mechanism that makes the set atomic. They also solve a less obvious problem: an install is not one operation but many, spread over minutes of download and seconds of verification, and the device can lose power in the middle of any of them.
A session is a staging area with a transaction boundary
A session is created with a set of parameters — the package name it targets, the install mode, whether it is staged — and then receives one or more APKs, each written into a named stream. Writing a stream is a normal file operation: open a stream by name, write at an offset, fsync, close. The session does not validate anything at write time. It is a container for bytes until the client asks it to commit.
The commit is the transaction boundary. At commit, the package manager performs the whole verification sequence: parse each APK’s manifest, confirm that the package names agree and that the split names are a valid set for that package, verify each APK’s signature against the same signing identity, check that the version code is an acceptable successor to the installed one, and confirm that the requested permissions and the declared SDK range are consistent. Only then does it make the new files visible.
The ordering matters because the checks are not independent. A split’s manifest fragment contributes components to the merged manifest, so a split that names a component conflicting with one in the base is only detectable once both are parsed. A signature check is per-APK, but the set is only acceptable if all APKs share one signer — a split signed by a different key is a split that could inject code into another developer’s app, which is why the check is a set-level equality rather than a per-file validity check.
Why the commit cannot be a directory rename
The obvious implementation of atomic replacement is a directory swap: write the new package into a fresh directory, then rename it over the old one. Filesystems give rename atomicity for a single directory entry, and the swap would be invisible to readers.
That approach fails on Android for two reasons. The first is that an app’s data directory is not part of the package directory, and the install must preserve it while changing the code; the atomic unit spans two locations with different lifecycles. The second is that the package manager’s state is not only the filesystem: it holds parsed manifest data, permission grants, component registrations, and the split dependency graph, and a rename that changes the files without updating that state produces a device that boots into an inconsistent package database.
The actual mechanism is a two-phase update to a database plus a filesystem move, with the database write ordered so that a crash at any point leaves a recoverable state. The session’s files are written under a staging directory that the package manager owns; at commit it writes the new package record with a flag indicating the install is in progress, moves the files, then clears the flag. On boot, a record still flagged in progress is treated as a failed install and rolled back, because the files it references may be half-moved.
That boot-time reconciliation is what makes the whole thing work, and it is also where the cost sits. The check runs on every boot for every package, and it must be cheap; the practical implementation is a single flag read per package rather than a filesystem scan.
Staged sessions and the pre-reboot install
A staged session is one whose files are committed to their final location but whose activation is deferred until the next boot. The device downloads and verifies the update in the background, marks it ready, and the running app continues on the old code until the device restarts.
The reason to do this is that replacing the code of a running process is not possible in general. The package manager could kill the app and restart it, but that loses the user’s state in whatever they were doing, and for a large app the restart is expensive. Deferring to a reboot moves the disruption to a moment when nothing is running, which is why staged installs are the default for large updates and for updates delivered while the device is idle and charging.
The cost is latency. An update staged today may not activate for days, because reboots are infrequent. That changes what a staged rollout measures: the offer percentage advances quickly but the activated population lags far behind it, and a defect in a staged build may not manifest until a large fraction of the cohort has rebooted. Rollout analysis has to distinguish offered, downloaded, staged, and activated as separate populations, because the crash rate of the activated set is the only one that describes the new code running.
Staging also requires storage. The new package’s files coexist with the old ones until activation, so the device needs free space for both. A staged install on a nearly full device fails at the write, not at the verify, and the failure is reported as insufficient space rather than as a verification error.
Streaming verification and the v4 side file
A session can begin receiving bytes before the download completes, and the verification has to keep up. Whole-file digest verification as described in APK signing block layout cannot do that, because it needs the entire file to compute the top-level digest.
Scheme v4 addresses this with a separate file alongside the APK containing a Merkle tree over 4 KiB pages of the APK, with the root signed. Verification becomes: as each page arrives, hash it and check it against its leaf; at the end, check the tree’s root against the signature. Pages can be verified out of order and in parallel, and a page that fails can be re-fetched without restarting the download.
The same structure supports a stronger runtime property. With the tree’s root pinned and the filesystem able to enforce per-page hashes, the kernel can verify pages as they are faulted in, so a modification to the installed APK after installation is detected at execution time rather than at the next update. That is the fs-verity model: the file is immutable, the Merkle tree is stored out of band, and the page cache checks each page against the tree as it reads it. For an app whose code is memory-mapped, this closes the window between install-time verification and execution.
The trade is that fs-verity files cannot be modified in place, and enabling it costs a tree of roughly 1/128th of the file’s size — one 32-byte hash per 4 KiB page. For a 40 MB APK that is a few hundred kilobytes, which is negligible; for a file that changes, it is unusable.
Abandonment, timeouts, and orphaned sessions
A session that is never committed holds storage indefinitely unless something reclaims it. The package manager therefore tracks session age and abandons sessions that have been inactive past a threshold, which frees the staged files. The client is expected to abandon explicitly on failure, and a client that crashes without doing so leaves the session to the timeout.
The timeout is a correctness issue rather than a housekeeping one. A session’s files were written under the assumption that the package they target is still installed at the version the session checked at creation. If the app is updated or uninstalled while the session is open, committing the session would install a set of splits against a base that no longer exists. The package manager re-checks at commit and refuses, so the stale session fails safely — but the client’s error handling must treat “the target changed” as a distinct outcome from “the bytes were corrupt”, because the remedy differs: refetch against the new base rather than retry the same download.
Two clients can also hold sessions for the same package concurrently, which the storefront and a sideloading tool can do without coordination. The second commit wins and the first fails, and there is no locking that would let a client reserve the package. Serialising at the client is the only defence, and it is why installers that run in the background must treat a commit failure as possibly transient rather than as evidence about the package.
What atomicity does not give
A committed install is atomic with respect to visibility, not with respect to the app’s own data. The app’s files, databases, and preferences carry over unchanged, and if the new version’s schema is incompatible with the old data, the install succeeds and the app fails on first launch. The installer has no knowledge of the app’s internal state and cannot check it; the migration discipline described in version code monotonicity is the app’s responsibility, and it is the failure mode that install-level atomicity most often hides.
Nor does the session protect the download. Bytes written into a session are not verified until commit, so a client that streams a corrupt payload wastes the whole transfer before finding out. Verifying incrementally against the v4 tree is the mitigation, and it requires the client to have the tree before the payload — which means the store must serve the side file first, and the client must be able to fetch it independently of the APK.
References
- PackageInstaller — Android Developers
- Android App Bundle format — Android Developers
- APK Signature Scheme v4 — Android Open Source Project
- fs-verity — Linux kernel documentation