Skip to content

Bind an empty directory over /data/resource-cache, not a tmpfs - #1030

Merged
JingMatrix merged 1 commit into
masterfrom
resource-cache-bind
Oct 24, 2026
Merged

JingMatrix merged 1 commit into
masterfrom
resource-cache-bind

Conversation

@JingMatrix

Copy link
Copy Markdown
Owner

/data/resource-cache has been shadowed in the daemon's mount namespace since LSPosed/LSPosed#1627 of February 2022, whose entire justification was "to avoid overlay conflict". Nobody wrote down what that conflict was, which is why the line was dropped in 156c6ae and restored the next day in 3c020a9 on a suspicion that could not be confirmed. This records the reason and keeps the shadowing, but pays for it with a mount that leaves nothing to count from outside the namespace.

The daemon is a root app_process, and it initializes framework assets twice over: once through ActivityThread.systemMain(), which builds a system context, and once through the AssetManager behind FileSystem.resources, whose public constructor and Resources.getSystem() both land in createSystemAssetsInZygoteLocked. On Android 11 and later that method asks OverlayConfig for the immutable framework idmaps. The comment on createImmutableFrameworkIdmapsInZygote says only the zygote may invoke it, and nothing enforces that: it forks idmap2 create-multiple, which writes into kIdmapCacheDir, that is /data/resource-cache, through a plain std::ofstream with no lock held.

The zygote reaches the same code from preload() by way of Resources.preloadResources, and only creates /dev/socket/zygote after preloading has returned. A daemon running alongside it therefore derives the same idmap paths and can truncate the file the zygote is writing, which is the overlay conflict of #1627; the up-to-date check that normally makes the second writer a no-op is no help while the first writer is still mid-file. On Android 10 and earlier the same entry point instead read /data/resource-cache/overlays.list and raised IllegalStateException when a listed idmap failed to load, so a list left behind by the previous boot could take the daemon down before it started.

That window has been narrow since LSPosed/LSPosed#1748 added the wait on the zygote socket, and narrower still now that service.sh is the only launcher. The daemon nevertheless forks idmap2 on every start and writes whatever the directory it is shown happens to lack, so the shadowing stays.

What does not stay is the tmpfs. A filesystem with no block device draws its dev_t from one kernel-global pool: get_anon_bdev() returns the lowest free id and holds it until kill_anon_super() releases it. The live set is dense from below, so a minor missing from a process's own mountinfo is not a free slot but a superblock that is alive and invisible from there, including one mounted only in a daemon's private namespace, which no per-namespace check can reach. A tmpfs over /data/resource-cache is exactly such a superblock, and the gap it leaves is readable by any app on the device: JingMatrix/Demo@1f91ee6. A bind mount allocates no dev_t at all, since it reuses the superblock of its source, so binding an empty directory of our own hides the cache just as well while allocating nothing. The directory is wiped and recreated on every start, so the idmaps regenerated inside it do not accumulate, and the daemon keeps its framework overlays as it did before.

The change is one file, zygisk/module/daemon: mount tmpfs -t tmpfs /data/resource-cache becomes a bind mount of $dir/.resource-cache, with the reason for the mount written above it.

The daemon has shadowed /data/resource-cache since LSPosed/LSPosed#1627,
whose only stated reason was "to avoid overlay conflict". The mechanism
was never recorded, which is why the line was dropped in 156c6ae and
restored the next day in 3c020a9 on a suspicion nobody could confirm.

It is this. The daemon is a root app_process that initializes framework
assets, and AssetManager.createSystemAssetsInZygoteLocked asks
OverlayConfig for the immutable framework idmaps; that call forks
`idmap2 create-multiple`, which writes into kIdmapCacheDir, that is
/data/resource-cache, under no lock. The zygote does the same during
preload, before it creates /dev/socket/zygote, so a daemon starting
alongside it can truncate the files the zygote is writing. On Android 10
and earlier the same entry point instead read
/data/resource-cache/overlays.list and threw IllegalStateException when
a listed idmap failed to load, so a list left by the previous boot could
take the daemon down. Shadowing the directory keeps the daemon out of
both.

A tmpfs pays for that with a global trace. A filesystem with no block
device draws its dev_t from one kernel-global pool: get_anon_bdev()
returns the lowest free id and holds it until the superblock dies, so
the live set is dense from below and a minor missing from a process's
own mountinfo is not a free slot but a superblock alive elsewhere,
including one in a private mount namespace that no per-namespace check
can reach. A bind mount allocates nothing, since it reuses the source
superblock. Bind an empty directory of our own instead, recreated on
every start so that nothing accumulates in it. See
JingMatrix/Demo@1f91ee6
for the check this closes.
@JingMatrix
JingMatrix merged commit 82112f9 into master Oct 24, 2026
1 check passed
LxyaoX added a commit to LxyaoX/Vekta that referenced this pull request Oct 25, 2026
Sync 69 upstream commits. Conflict resolutions:
- build.gradle.kts: keep fork minSdk 31 / arm64-only / tag filter, adopt upstream NDK r30
- gradle wrapper: keep 9.8.2 with matching wrapper jar (upstream moved to 9.8.0)
- gradle/libs.versions.toml: keep fork (kotlin 2.4.21, m3 1.5.0-beta01, navigationevent 1.2.0 are
  newer than upstream; remaining bumps already identical)
- .gitmodules: keep fork (libxposed submodules stay removed, upstream mirror URLs not applicable)
- zygisk/module/daemon: keep fork JDWP/Vekta tags, adopt upstream bind-mount fix for
  /data/resource-cache (JingMatrix#1030)
- README.md: keep Vekta branding, adopt upstream libxposed mirror link

Co-authored-by: JingMatrix <[email protected]>
LxyaoX added a commit to LxyaoX/Vekta that referenced this pull request Oct 25, 2026
Carry over the upstream deltas for files that both sides changed and the fork keeps its own version of:
- build.gradle.kts: NDK r30 (30.0.16248370)
- README.md: libxposed API mirror link
- zygisk/module/daemon: bind-mount an empty directory over /data/resource-cache instead of tmpfs (JingMatrix#1030)

Co-authored-by: JingMatrix <[email protected]>
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