The per-thread retry entries are a 64-entry cache. More threads faulting in
turn on a page whose protection does not explain the fault evict each
other's entries before their next attempt, so each count restarted at 1 and
the faults were retried forever. Retries between two untraps are now also
counted in total, up to 64 times the per-thread budget.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A caller that traps the same range at every label then needs no record of
its own of which ranges are trapped, which registering memory again could
leave stale.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A fault that reaches the handler after its page was given back is retried.
The budget for that was shared by every thread faulting on the page, so the
17th delayed reader of one untrap was passed on as a crash. Each thread now
has its own budget per page and untrap, which still bounds a fault the page's
protection does not explain.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
GuestWriteWatchTrapReads takes all access to the pages of a range until
the resolver set with GuestWriteWatchSetReadTrapResolver has run for it.
A fault on a trapped page waits while the resolver runs on the watch's
own thread; host writes and GuestWriteWatchProtect run it first on the
calling thread. Collections keep trapped pages inaccessible, and
registering or unregistering memory drops its traps.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
libSceAgcDriver decides per 64 KiB block whether a pending storage image
keeps the CPU's bytes. A window that ran into the next block marked it
CPU-written and dropped the GPU results there (agc_storage_write_back,
agc_idle_publish_wait, array layer and depth plane tests).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A guest thread that writes a large watched range in order takes one
write-watch fault per page. Under Rosetta 2 each fault is a Mach
exception round trip. When the two pages before the faulting one were
written since the last collection, the handler now unprotects up to 32
pages from it that have the same protection and records them as
written. APS5_WRITE_WATCH_FAULT_AHEAD sets the count; 0 or 1 keeps one
page per fault.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The macOS write watch handles SIGBUS and SIGSEGV for the whole process.
When a signal is not a write to a watched page and no handler was
installed before it, the handler restored the default action and
returned. That ends the process for a real fault, which runs again and
faults with the default action, but a SIGBUS or SIGSEGV sent with raise
or kill was swallowed: the process kept running. A spawned child that
raised SIGBUS exited with its next exit code instead of dying with the
signal, so guest_posix_spawn failed on macOS.
The handler now raises the signal again after restoring the default
action. It is blocked until the handler returns, so a real fault still
ends with the same signal, and a sent one now does too. An inherited
SIG_IGN keeps the old behaviour.
- mlock on macOS wires only the pages libkernel has not wired yet. XNU
counts each wire of a page, and the locked ranges merge overlapping
locks, so a page locked twice stayed wired after munlock and
munlockall. The mlock test checks the wire count on macOS.
- mprotect changes a watched page through GuestWriteWatchProtect, which
applies the host protection and records it under the write watch's
lock, so a collection on another thread cannot run between the two.
The write watch test covers it.
- sceKernelVirtualQuery takes a watched page's write permission from the
write watch for memory outside the guest allocation registry.
- GuestAllocationsGuestPages counts an image segment up to the end of
its last host page, as the image page collection does.
- The macOS includes stay in main's include block, so the host code
other pull requests add there still sees them; the unused Mach-O
headers and the end trim, which never changed the protected spans,
are gone, and the guest page size is passed in.
- The libc and libkernel debt entries describe what is wired now.
GuestWriteWatchCovers says whether a range is watched in full. The
Linux and macOS watches unregister page ranges, so a range can hold
watched pages between unwatched ones, and code that must act on every
watched byte of a store has had no way to find them short of asking
page by page. GuestWriteWatchOverlaps answers with one lookup in the
registered ranges, like Covers.
The macOS write-watch test and agc_write_tracking on Linux check it
around watched pages between unwatched ones, at both edges and for an
empty range.
On macOS, GuestWriteWatchCollectFresh reported nothing and returned true, so
MarkWritten took pages no collection had armed yet for clean, and they stayed
writable without being tracked. GuestWriteWatchCollectArmed was a full collect,
which also took those pages.
Pages now carry a fresh bit from registration until a collection takes them, as
the Linux watch keeps its fresh ranges: CollectFresh reports and arms only fresh
pages, CollectArmed skips them, and Collect takes both. Protection changes, host
writes and faults keep the bit.
The write_watch fresh and armed tests check both collections.
Register copied the protection it had recorded for pages it already
watched over the protection the kernel had just reported. A guest that
reserves address space, which registers it as PROT_NONE, then maps
direct memory over it read-write and registers it again kept the stale
PROT_NONE. Collect only write-protects pages with PROT_WRITE, so writes
to that memory were reported once and never again. With PPSA12544 on
the macOS port, the title menu text blinked and glyphs went missing.
Register now keeps a recorded protection only where its own write
protection explains what the kernel reports, and takes the kernel's
protection otherwise. A protected page that was mapped again also loses
its protected mark, so erase no longer restores the old protection over
the new mapping. The kernel is read under the lock, so a concurrent
Collect cannot protect a page between the read and the merge.
Test write_watch_mapped: a reservation, a mapping over it and a second
registration keep reporting writes, and a protected page reserved again
stays PROT_NONE.
AI-assisted: yes
macOS has no counterpart to userfaultfd write-protect or
MEM_WRITE_WATCH, so GuestWriteWatch had no backend there and the driver
re-read every guest buffer on each use.
The macOS backend write-protects watched pages. The first write to a
page faults, the SIGBUS/SIGSEGV handler records the page and restores
its protection, and Collect reports the recorded pages and protects
them again. A page is protected before its record is cleared, under the
lock the handler takes, so a write that lands in between is recorded.
The lock hands out turns in order: an unfair one starved writers under
Rosetta.
The protection is real, which adds three entry points:
- GuestWriteWatchBeginHostWrite/EndHostWrite lift it around a system
call that writes guest memory, which would otherwise fail with
EFAULT. GuestArena::HostWrite calls them, as it calls the Windows
equivalent.
- GuestWriteWatchProtectionChanged tells the watch the protection given
to watched pages by mprotect or a new mapping.
- GuestWriteWatchProtection reports that protection, since a watched
page is read-only between a collection and the next write.
Register keeps the recorded protection of a page it already watches:
read from the kernel it would be the watch's own read-only protection,
and the page would never be watched again.
The tests build without the prx libraries and run natively and through
Rosetta.
Co-Authored-By: Claude Opus 5.5 <[email protected]>