Skip to content

fix(android): treat SEGV_MAPERR like SEGV_ACCERR in the write-tracking fault path - #41

Open
ptichalouf wants to merge 1 commit into
Nycolazs:mainfrom
ptichalouf:fix/android-segv-maperr-write-tracking
Open

ptichalouf wants to merge 1 commit into
Nycolazs:mainfrom
ptichalouf:fix/android-segv-maperr-write-tracking

Conversation

@ptichalouf

Copy link
Copy Markdown

Problem

The Android runtime tracks which guest-heap pages the OS wrote to, and protects
them read-only so the guest can be told about the write later
(plat_written_pages -> mprotect(PROT_READ)). The crash handler only took
the write-access recovery path for SEGV_ACCERR:

if (sig == SIGSEGV && si->si_code == SEGV_ACCERR && wt_fault(fa)) return;

When mprotect() splits a VMA, the kernel can resolve the concurrent guest
write against a VMA that is being replaced and report SEGV_MAPERR
(si_code=1)
- even though the page stays mapped and resident. That fault was
therefore treated as fatal: the game died with a SIGSEGV instead of recovering
the write access.

Measured on a Galaxy Z Fold4 (arm64, 4 KiB pages), 20 repetitions per arm:

Arm Protocol MAPERR
A memset(352 KiB) on an r--p page, alone 0/20 (always ACCERR)
B same + one thread mprotects the same page 20/20 (not recoverable)
C same + one thread mprotects a different page 20/20 (not recoverable)
D same, one-byte write 3/20

Arm A matters: a single-threaded probe on the same page and the same size
never produces MAPERR. The concurrency is the discriminating factor.

The si_code really comes from the kernel

The upstream binary fault trace captures the siginfo at the first front
assembly, before any signal chaining, so it cannot be rewritten by the ART
chain. From a run without the fix:

sig=11  si_code=1  addr=0x7285dc5000
x0=0x7285dc5000 (dst)  x1=0x0  x2=0x56000 (352 KiB)

The faulting instruction is a memset(dst, 0, 352 KiB), and the kernel reports
si_code=1.

Fix

Treat both codes the same way - the page needs its write access back:

if (sig == SIGSEGV && (si->si_code == SEGV_ACCERR || si->si_code == SEGV_MAPERR) && wt_fault(fa)) return;

This is safe on a genuinely unmapped page: mprotect() returns ENOMEM, and
the 64 KiB neighbourhood attempt fails too, since mprotect() needs the whole
range mapped. A real hole still falls through to the diagnostic path below, so
there is no silent corruption and no infinite loop.

Result

Same build, same device:

before after
crash SIGSEGV at 3 presented frames none
presented frames 3 (dead) 14611
heap faults 1 0
signals 1 0

The fault trace file, the probe sources (kernel_probe6.c, kernel_probe7.c,
arm64 NDK r27c) and the device logs are available on request.

Scope

One file, one line of logic. The comment style matches the rest of this file.
No behaviour change for a page that is genuinely unmapped, and no change to the
SEGV_ACCERR path.

@ptichalouf

Copy link
Copy Markdown
Author

Follow-up: independent verification of the decision logic

The change is one line, so I built a small ad-hoc control rather than relying on the device run alone. It extracts the real wt_fault() and the real dispatch condition verbatim from the file under test (no rewrite), compiles them with a driver, and exercises the decision matrix.

The driver reproduces the device situation: the guest-heap page is mapped and resident, and the kernel reports a given si_code.

Arm si_code Fixed file Upstream file
accerr 2 (SEGV_ACCERR) write path taken write path taken
maperr 1 (SEGV_MAPERR) write path taken refused
other 3 (unrelated) refused refused
hole genuinely unmapped page refused (mprotect -> ENOMEM) refused

All 8 expectations pass. Two things make the control meaningful rather than decorative:

  • It discriminates. The same harness run against the unfixed upstream file rejects the MAPERR arm, so it is measuring the change and not passing by construction.
  • It rejects broken fixes. Two deliberately broken variants were injected: wt_fault() always returning true, and the si_code comparison dropped. The harness rejected both - the first on the hole safety arm (it would silently accept a real hole), the second on the other arm.

Two limits, stated plainly:

  • The mprotect race that makes the kernel report MAPERR is specific to the Android kernel on the device: a host probe (200 iterations of a concurrent VMA split against a resident read-only page) gave ACCERR 200/200, MAPERR 0/200. So the control verifies the decision logic and its safety, not the kernel behaviour - that part rests on the device measurement (20/20 MAPERR with a concurrent mprotect, 0/20 without).
  • This is an ad-hoc control, not the repository's test suite, which needs the Android NDK and a device.

The control and its transcript are in the PR's working folder if you want to run them.

…g fault path

The guest-heap write tracking (plat_written_pages) protects pages read-only
while the guest writes to them. When mprotect() splits a VMA, the kernel can
resolve the concurrent guest write against a VMA that is being replaced and
report SEGV_MAPERR (si_code=1) even though the page stays mapped and
resident. The crash handler only tested SEGV_ACCERR, so the fault was treated
as fatal and the game died instead of taking the write-access path.

Measured on a Galaxy Z Fold4 (arm64, 4 KiB pages):

  A  memset(352 KiB) on an r--p page, alone          0/20  (always ACCERR)
  B  same + one thread mprotects the same page       20/20  (not recoverable)
  C  same + one thread mprotects a different page    20/20  (not recoverable)
  D  same, one-byte write                            3/20

The upstream binary fault trace confirms the kernel itself reports si_code=1
(sig=11 si_code=1 addr=0x7285dc5000, x0=dst x1=0 x2=0x56000, i.e. a
memset(dst, 0, 352 KiB)), so the code is not rewritten by the signal chain.

wt_fault() is safe on a genuinely unmapped page: mprotect() returns ENOMEM
and the 64 KiB neighbourhood attempt fails too, since mprotect() needs the
whole range mapped. A real hole therefore still falls through to the
diagnostic path below.

With the fix, the same build runs 14611 presented frames with 0 crashes,
0 heap faults and 0 signals, against a systematic SIGSEGV at 3 presented
frames before.
@ptichalouf
ptichalouf force-pushed the fix/android-segv-maperr-write-tracking branch from 4d08cdb to 9f2836c Compare October 29, 2026 13:12
@ptichalouf

Copy link
Copy Markdown
Author

Update: the comment was tightened, and the control was re-run against the published blob

Two changes since the previous comment:

1. The comment in the code was shortened (4d08cdb -> 9f2836c, still +8/-1, still one file, still the same single line of logic). The first version ran to twelve lines; this file explains itself in a few prose lines, so the comment now matches the surrounding style. The code line itself is unchanged and is byte-for-byte the line that was validated on the device (build11, sha256 d8788315…).

2. The control was re-run against the published blob, not against a working copy. The harness now takes runtime/platform/android.c straight from the PR head (9f2836c, sha256 8189989932d51f4e…) and still gets 8/8, with the same discrimination and the same mutation results:

Arm si_code Fixed (published) Upstream
accerr 2 write path taken write path taken
maperr 1 write path taken refused
other 3 refused refused
hole genuinely unmapped refused (ENOMEM) refused

The two deliberately broken variants are still rejected: wt_fault() always true fails the hole safety arm, and dropping the si_code comparison fails the other arm.

The limits from the previous comment still stand unchanged: the mprotect race that makes the kernel report MAPERR is specific to the Android kernel on the device (a host probe gives ACCERR 200/200, MAPERR 0/200), so this control verifies the decision logic and its safety, not the kernel behaviour. And it is an ad-hoc control, not the repository's test suite.

@congyoubanmian

Copy link
Copy Markdown

Confirmed on real hardware — this PR's diagnosis matches a crash we hit on a Huawei tablet.

Device: Huawei MatePad 11 (GOT-W29), HarmonyOS 4 / Android 12 (API 31), Snapdragon 870 (SM8250), Adreno 650, 8 GB RAM, not rooted
Build: LAS Recomp Android 1.2.0, game v1.0.0 (main build ID AE16F71E002AF8CB), bundled Turnip driver

After a clean setup the game boots and plays, but it dies intermittently across several game threads (guest 101, guest 130, guest 149, guest 13b, render), always with the same signature:

[mem] heap fault, si_code 1, tracking 1
*** signal 11 accessing 0x1240000048

0x1240000048 is HEAP_BASE+0x48 — the very address the comment at runtime/platform/android.c:69 mentions ("...HEAP_BASE+0x48 on starting the game"). With the current handler, si_code=1 (MAPERR) falls through to the fatal path because only SEGV_ACCERR is recovered — exactly as this PR describes.

Workaround check: LARECOMP_WRITE_TRACKING=0 in env.txt (tracking off, "every range counts as written") makes the crashes go away completely — the game then runs stable on this tablet. So the fault is confirmed to be in the tracking / ACCERR-only recovery path, not in the game code.

As a control: the same APK with the same game files runs fine on a non-Huawei Android phone, so this is ROM/kernel-specific — Huawei's kernel resolves these guest writes as MAPERR where others report ACCERR.

Happy to test a build with this patch on the tablet if useful.

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.

2 participants