Files
MobileGL/docs/Disaggregated/notes/p7/x1-persistent-map-segv.md
T
swung0x48 8a445a3098 [docs] (P7): every cited feat/disaggregated commit is the rewritten one without the attribution trailer
- 144 commits from 785fed0b0 onward were rewritten to drop the Co-Authored-By trailer the
  project forbids; origin/feat/disaggregated moved from 1135c664 to 3c80cd62 (same trees)
- the old->new map is ~/w7/logs/sha-map-20260922.txt; every 7..40-hex citation in the
  Disaggregated docs and the MG_Remote contracts that matched an old SHA is replaced by the
  same-length prefix of its rewrite; no other content changes
2026-09-22 20:51:15 -04:00

170 lines
10 KiB
Markdown

# X1 — the client-thread SIGSEGV in a mapped buffer under inproc (device window #1)
Wave 2, package X1. Base `4f2d4c4e`. Branch `p7/segv`.
## 1. What the tombstone actually said
Three CI split-subset traces died on the Redmi (`2f7cbe2e`, Adreno 830, APK `p7w1-9be62cbc`)
under `MOBILEGL_TRANSPORT=inproc` and passed under monolith on the same device and APK:
`improved-transparency-minecraft-26.3`, `minecraft-1.21.1-neoforge-create-instancing-in-world`,
`minecraft-1.21.1-neoforge-create-indirect-in-world`. Every one of them is
`SIGSEGV / SEGV_ACCERR` inside `__memcpy_aarch64_nt`, on the `MobileGLTraceRe` (GL/client)
thread, in the apitrace replayer's write into a pointer MobileGL had handed it.
The deciding evidence is two lines of the tombstone that name **the same address twice, in two
different address spaces**:
```
x0 b4000073ac9a5000 x1 b4000073ab1a2000 x2 0000000001800000
signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x00000073ac9a5000
```
`x0` is the memcpy destination — the pointer the application is writing through. `0xb4` in its
top byte is bionic's `POINTER_TAG`: on a TBI-capable arm64 every heap pointer carries it
(`tagged_addr_ctrl: 0000000000000001 (PR_TAGGED_ADDR_ENABLE)`, same tombstone). `fault addr` is
`info->si_addr`, and the kernel reports it **untagged**. `x2` is the copy length, 24 MiB. The
fault is on the destination's *first* byte, and the destination is page-aligned — which is the
shadow allocator's signature (`PipeResource.h:77`, `::operator new(…, align_val_t{4096})`).
All four crash sites have exactly this shape:
| case | x0 (tagged dst) | si_addr | x2 (length) |
|---|---|---|---|
| 26.3, DirectVulkan (E0a) | `b4000073ac9a5000` | `0x73ac9a5000` | `0x1800000` |
| 26.3, DirectGLES (E4a) | `b4000073b1d39000` | `0x73b1d39000` | `0x1800000` |
| 26.3, DirectGLES, block=0 (E4b) | `b4000073ab788000` | `0x73ab788000` | `0x1800000` |
| create-instancing (E0a) | `b40000739de43000` | `0x739de43000` | `0x5e10` |
| create-indirect (E0a) | `b40000739f5e8000` | `0x739f5e8000` | `0x7000` |
(The logcat tombstone carries registers and backtrace but not the `memory map` section —
that lives only in `/data/tombstones/`. The registers were enough.)
## 2. The mechanism
`MG_Remote/Client/PersistentMapTracker.cpp`, two rows that never lived in the same address
space:
* **Registration** (`TrackWriteMap`, formerly `:348`) took `shadowBase =
(uintptr_t)buffer.MappedData()` — a **tagged** heap pointer — derived `base`/`end` from it
(`& kPageMask` clears the low 12 bits and preserves the top byte), `mprotect`ed
`[base, end)` `PROT_READ`, and published the **tagged** `base`/`end` into the slot.
* **The handler** (`PersistentWriteFaultHandler`, formerly `:218/:231/:234`) took
`address = (uintptr_t)info->si_addr` — **untagged** — and tested
`if (base <= kSlotSettingUp || address < base) continue;`.
`0x73ac9a5000 < 0xb4000073ac9a5000` is true, for every fault, forever. Ownership test (2) never
matched, `matched` stayed `false`, and every fault on a page the tracker had itself protected
fell through to `ChainToPreviousSegvHandler` → bionic's debuggerd → tombstone.
`mprotect` itself was fine: `do_mprotect_pkey` calls `untagged_addr()` on its argument, so the
right pages really were protected. Only the *recognition* was broken.
The other half of the defect is the ordering that made the handler load-bearing at all:
`BufferObject::AcquireMemoryRange` sets `m_isMapped`, calls `NotePersistentMapStateChanged()`
(which lands in `TrackWriteMap` and protected the range), and only *then* returns
`m_resource.Bytes() + range.start` (`BufferObject.cpp:913-965`). So `glMapBufferRange` with
`GL_MAP_WRITE_BIT | GL_MAP_PERSISTENT_BIT` handed the application a **read-only** pointer whose
writability depended entirely on a SIGSEGV handler reaching the writing thread.
### Why every fact fits
* **E1 (`RUN_AHEAD=0`) still crashes** — nothing here touches run-ahead.
* **E4a (DirectGLES) still crashes** — the tracker is backend-independent; it sits in the
client's `MG_State` map path.
* **E4b (`PERSISTENT_BLOCK_KB=0`) still crashes** — the knob gates `PushBlocksFor`
(`:849`, the `blockBytes == 0` early return), *not* `NoteMapStateChanged`/`TrackWriteMap`.
The mprotect arm is armed with the push off.
* **No tracker warnings in the client log** — registration succeeded; nothing on the
successful path logged anything.
* **Crashes early** (client log ends right after `CapsMirror generation 3 adopted`) — the
first persistent write map of the trace is the first and last one.
* **WSL/lavapipe green** — x86-64 has no top-byte tag, so `base` and `si_addr` agree and the
handler works. This is why every host lane has always been green.
* **Monolith green on the same device** — `NotePersistentMapStateChanged` returns immediately
when `PushIsArmed()` is false (`BufferObject.cpp:489`), so nothing is ever protected.
### Ruled out
ART's `libsigchain` is not implicated: the tombstone's `F libc : Fatal signal 11` line is
bionic's `debuggerd_signal_handler` output, which proves the signal *was* delivered to a
userspace handler chain and that our handler ran and chained. A blocked SIGSEGV would have had
the kernel force `SIG_DFL` and produced no such line. The 16 KiB-page refusal (`:274-278`) is
not reached (the device is a 4 KiB kernel — the protection demonstrably took). The
`RefaultOfANonWriteAccess` decline (`:156`) is not reached either: `matched` is false long
before it is consulted. P5d's `SHADOW_ALLOCATION_ALIGNMENT` / zero-page registration are
correct and are in fact what makes the destination page-aligned in the tombstone.
## 3. The fix
`MG_Remote/Client/PersistentMapTracker.cpp`:
1. **`UntagAddress()`** — one normalisation, applied to the shadow base at registration
(`TrackWriteMap`), to the shadow base in `PushBlocksFor` (so `trackedBase - shadowBase`
stays in one space), and to `si_addr` in the handler. Written **without** an
`#if defined(__aarch64__)`: masking bits 56-63 is the identity for every valid userspace
address off arm64 (Linux/x86-64 caps `TASK_SIZE_MAX` below 2^56), and an arch guard would
have put the only gate that can catch this on the one platform CI never runs — which is how
it reached the device. Pointer *dereferences* keep the tagged pointer they came from
(`PushBlocksFor`'s two edge hashes now read through `shadowBytes`), because under a future
real-MTE tagging level the tag is load-bearing for the access itself.
2. **`SlotOwnsFault()`** — ownership test (2) extracted into one row the handler and the unit
test both read.
3. **Registration arms nothing.** The `mprotect(base, end - base, PROT_READ)` at the end of
`TrackWriteMap`'s slot setup is gone. Nothing is lost: every page bit is already SET at
registration, so the first push ships the whole interior regardless, and it is that push
(`PushBlocksFor`, `:775`) that arms the pages — which is where the dirty tracking has its
first real question to ask. Arming at registration could only ever produce faults whose
answer was already known, one per page: 6144 of them for the 24 MB arena that crashed.
The invariant the file rests on — *bit set ⇔ page writable* — is now true from registration
onward, where before it was knowingly false for the whole pre-first-push window.
### The diagnostic (independent of the fix)
* **One line per process at the first registration** (`MGLOG_I_ONCE`), naming the mapping the
way the device run needed and did not have: allocator, the pointer the application will be
handed, **its tag**, the extent that pointer owns, the mapped range, the published span, the
page count and which alignment arm produced it.
* **A decline record in the handler**, written with relaxed atomic stores only (async-signal-
safe), readable from the GL thread through `PersistentMapTracker::DeclinedFaults()`, plus one
async-signal-safe `write(2)` line to fd 2 the first time a decline happens while any slot is
live. A non-zero decline count whose `si_addr` lies inside `nearest-tracked` **once the top
byte is masked off** is the signature of this defect and of nothing else.
## 4. Red-once (two, orthogonal)
`MG_Test/Wire/PersistentMapFaultTest.cpp`, six cases, registered in
`MG_Test/Wire/CMakeLists.txt`'s `wiretest` loop.
| revert | red | green |
|---|---|---|
| drop the two `UntagAddress` calls in `SlotOwnsFault` | `AKernelsUntaggedFaultAddressIsOwnedByATaggedShadowsSlot` | the other 5 |
| restore `mprotect(base, end - base, PROT_READ)` at the end of `TrackWriteMap` | `AMapHandsBackARangeWritableWithoutTheFaultHandler` (child dies by SIGSEGV), `RegistrationLeavesEveryPageOfTheRangeReadWrite` | the other 4 |
Neither revert can be hidden by the other. `AnArmedPageIsStillAnsweredByTheHandlerAndMadeWritable`
is the anti-vacuity case: it protects a page by hand the way the push does and asserts the
handler answers it, so the two writability cases cannot stay green with the whole mprotect arm
deleted.
The first case runs the **device's own two addresses** (`0xb4000073ac9a5000` against
`0x73ac9a5000`) through the real predicate, on an x86-64 host. That is the gate this defect
should have had.
## 5. What the integrator must still decide
1. **The device run.** The fix is blind with respect to the phone — see the experiment in the
hand-off. One `--matrix` pass over the three cases under inproc is the whole test.
2. **P5d's device numbers are suspect.** If registration always protected and the handler always
declined on arm64, then on Android the mprotect arm has never answered a single fault: every
persistent write map either killed the process or was never written through. Any
P5d measurement taken on the Redmi that attributes a win to the mprotect arm needs re-taking.
3. **Residual: a writer thread with SIGSEGV blocked.** After the first push the pages are armed
again while the map is live, so a write from a thread that blocks every signal is still a
process kill. Registration no longer creates that window, but the steady state does. If the
integrator wants it closed outright, the arm has to become opt-in per buffer (or the push has
to fall back to the hash arm for maps the application writes off the GL thread) — that is a
design change, not a bug fix, and it is not in X1.
4. **Residual: `RefaultOfANonWriteAccess`** can still decline a legitimate repeat write at the
same `(tid, pc, address)` when the page's bit is set. Deferring the arm removes the one
window that made "bit set while page read-only" routine, but the row is still reachable by a
sibling thread mid-answer. Not observed; recorded.