Skip to content

feat(libc): trap guest reads of write-watched pages on macOS - #13442

Merged
adriadam10 merged 5 commits into
boykopovar:mainfrom
Gonic0:feat/libc-write-watch-read-trap
Nov 1, 2026
Merged

adriadam10 merged 5 commits into
boykopovar:mainfrom
Gonic0:feat/libc-write-watch-read-trap

Conversation

@Gonic0

@Gonic0 Gonic0 commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

What

The macOS write watch gains read traps. GuestWriteWatchTrapReads(address, bytes) takes all access to the pages of a watched range (PROT_NONE) until the resolver set with GuestWriteWatchSetReadTrapResolver has run for it. GuestWriteWatchReleaseReads removes a trap without running the resolver. On Linux and Windows the three exports return false or do nothing.

Nothing sets read traps yet, as with GuestWriteWatchProtectionChanged. The intended user is a follow-up in libSceAgcDriver: StorageTexture::StoreReadbackBeforeLabel writes back every pending linear or small storage image at each label, whether or not the CPU reads it. With traps, the write-back can wait until a guest thread touches the memory. On PPSA21564 (ASTRO BOT 01.007.000), during the logos before the intro, skipping those write-backs (APS5_NO_LABEL_READBACK_STORE=1) gives 43.1 and 43.2 FPS against 37.6 and 37.5 with them, alternated, and the image stays intact. I then trapped those ranges with a temporary handler, without writing them back. In 75 s, every fault came from the emulator's own reads and writes, none from guest code. During the logos there were no faults. That is one title and one phase, so the follow-up keeps the write-back as the fallback. That follow-up is #13499. It traps those ranges at the label instead of writing them back, releases a trap when FlushPending stores its image, and resolves a trap through the host-read hook. In the same scene it gives 44.0 and 44.3 FPS against 38.1 and 37.1, alternated, with the same image.

How it works:

  • A page under a trap has a new state bit (0x80). The traps themselves are kept as exact ranges, so two images sharing a page each keep their own trap, and releasing one leaves the page trapped for the other.
  • A fault on a trapped page, read or write, is checked before record(). The faulting thread hands the page to a resolver thread and waits on a Mach semaphore. The resolver cannot run on the faulting thread: the handler is installed without SA_NODEFER, and a resolver storing into write-watched pages would raise the blocked signal again.
  • The resolver thread takes every trap on the page and every trap sharing a page with those, calls the resolver once per trap with its exact range, then gives the pages back the protection the watch records for them.
  • Faults on pages of a trap being resolved:
    • A fault from the resolving thread itself gets the page back at once, so the resolver can store into its range.
    • A fault from another thread waits until the resolution ends.
    • A fault that arrives after its page was given back is retried, at most 16 times per thread between two untraps, and at most 1,024 times in all between two untraps, so a fault that keeps coming back stays bounded when more threads fault than the 64-entry per-thread table holds. A fault the page's protection does not explain, such as a file mapping whose backing was truncated, is still passed on.
  • Collections keep trapped pages at PROT_NONE.
  • Trapping a range that is already trapped exactly does nothing and returns true, so a caller can trap the same range at every label without keeping its own record.
  • GuestWriteWatchBeginHostWrite, GuestWriteWatchProtect and GuestWriteWatchProtectionChanged run the resolver for the traps they cover first, on the calling thread, since a system call cannot write a PROT_NONE page and a new protection would erase the trap. A resolver may itself start a host write over its own range; the driver brackets GPU imports that way.
  • Registering or unregistering memory drops its traps without running the resolver, since the memory is being replaced.

#7192 adds GuestWriteWatchHandleFault, which calls record() directly; if both land, that entry point needs the same check for trapped pages first. The macOS write-watch entry in docs/dev/debt/libc.md gets the gaps:

  • a system call that reads a trapped page fails with EFAULT;
  • a resolver that stores with the CPU, or starts a host write, makes the pages accessible to other threads before it returns;
  • a thread that holds something the resolver needs must not touch a trapped range, or start a host write or protect over one being resolved: it would wait for the resolver forever;
  • a resolver that touches the pages of another trap takes them without resolving that trap.

Tested

Apple M5 Pro, macOS 27.2. Configuring core/libs/tests/write_watch on main fails on macOS because two test names are registered twice; #13220 fixes that. So I built write_watch_tests from this branch with clang++ -std=c++20 -O2 -Wall -Wextra, for x86-64 (run under Rosetta 2) and for arm64. I also built it with the project's CMake in a tree with the open macOS port PRs, where these files are identical. There, ctest -R '^write_watch_' passes 23 of 23. libc also builds there with the project's flags.

New test modes:

  • read-trap:
    • a read from another thread sees the resolver's bytes, and the resolver runs once with the exact range on the watch's thread;
    • the resolver's stores are collected as writes;
    • a released trap does not run the resolver, also after the same range was trapped twice;
    • a write to a trapped page lands after the resolver's bytes;
    • a collection keeps a trapped page at PROT_NONE;
    • two traps sharing a page;
    • a host write runs the resolver on the calling thread, after which read() from a pipe fills the page;
    • GuestWriteWatchProtect resolves the trap and applies its protection;
    • a resolver that brackets its stores as a host write;
    • unregistering restores the protection without running the resolver;
    • GuestWriteWatchTrapReads fails without a resolver, on unwatched memory and under a host write.
  • read-trap-threads: four threads read 32 trapped pages, two at a time on the same page. The resolver stores through a second mapping of the same file, as a GPU stores through a host import. Every read sees the resolver's bytes and each trap is resolved exactly once.
  • read-trap-fault: with traps set, a read of a watched file mapping whose backing was truncated still kills the process with SIGBUS instead of being retried forever.
  • read-trap-fault-bound: 66 threads read a watched file mapping whose backing was truncated, with a Mach exception port on each. The test passes their faults on as signals strictly in turn, so each thread's retry entry is evicted before its next fault, for 40 rounds. The process still dies with SIGBUS.
  • read-trap-late: 32 threads read a trapped page with a Mach exception port set on each, which holds their faults until all 32 have faulted. The page is then resolved, and only after that are the faults passed on as signals. All 32 handlers find the page given back and retry, and every read sees the resolver's bytes.

All write-watch modes pass on both architectures. read-trap, read-trap-threads, read-trap-fault, read-trap-late and read-trap-fault-bound passed 10 times each on both, and read-trap-threads 30 more times on each. The other tests in that tree that use the write watch, guest_arena_*, guest_memory_lifetime*, agc_driver_guest_memory_lifetime* and guest_memory_pool_stats, pass. guest_memory did not build there, for a sceKernelMunmap call unrelated to this change.

Negative controls, each of which fails a test:

  • record() before the trap check: "a write to a trapped page does not land after the resolver's bytes";
  • collections re-protecting trapped pages read-only: "a collection gives back the access to a trapped page";
  • no pass for the resolving thread's own faults: read-trap hangs until its alarm;
  • another thread's fault during a resolution taking the page: "a concurrent read of a trapped page does not see the resolver's bytes";
  • the resolver's nested host write waiting on its own resolution: read-trap hangs until its alarm;
  • no retry of a fault after its page was given back: read-trap-threads dies with SIGBUS;
  • unbounded retries: "a read fault on a mapping with missing backing is not delivered while read traps are set";
  • a second trap of the same range adding another: "a released read trap set twice does not give back the watch's protection";
  • no bound on all retries between two untraps: "66 threads that keep faulting on a truncated mapping in turn are retried without bound";
  • one retry budget per page for all threads, as before 73afc098f1: "32 read faults that arrive after their page was given back are not all retried";
  • host writes keeping the trap: "a host write over a trapped page does not resolve it first on its own thread";
  • releasing a trap ignoring the others on its page: "releasing one read trap gives back a page another one still traps".

Not tested on Linux or Windows, where the change adds three exports that return false or do nothing.

Checklist

  • Based on current main; no other open PR implements the same functions
  • One topic per PR; follow-ups go in a new PR
  • No comments in code except technical debt
  • Unimplemented paths throw (NotImplemented_nid_no_patch); silent stubs are listed under Silent stubs in their library's technical debt file
  • No notes, investigation .md files or images added to the repository (attach them to this PR)
  • New third-party code is a submodule built from source
  • Generic behaviour, not specific to one title
  • Depends on: test(libc): register the macOS write watch fault tests once #13220 (the write-watch tests configure only with it)
  • Fixes: none
  • Unblocks: none
  • GPUs tested: n/a
  • AI-assisted: yes

🤖 Generated with Claude Code

@oneandonlydean

Copy link
Copy Markdown
Collaborator

@Gonic0 retryUntrapped shares one 16-retry budget per page/generation across all threads. If more than 16 readers fault while a page is trapped, then reach the handler after the resolver has restored it, each reader consumes that same budget; the 17th legitimate read returns false and is forwarded to the previous SIGBUS/SIGSEGV handler. The new thread test uses only four threads, so it cannot catch this. Please distinguish repeated unresolvable faults from separate delayed readers and add a deterministic many-reader regression, while keeping the truncated-backing fault bounded. Also, the hosted write-watch job will stop at configure on this head until #13220 lands (main still registers the execute-fault/read-fault block twice), so could the PR body list #13220 under "Depends on"?

@Gonic0

Gonic0 commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Right: the budget was per page, so the delayed readers of one untrap shared it and the 17th was passed on. 73afc098f1 keys it by thread as well (64 entries, the least recently used one reused) and still resets it at each untrap of the page. Each delayed reader now gets its own 16 retries, and a fault the page's protection does not explain is still passed on after 16 on its thread (read-trap-fault).

The new read-trap-late mode makes the late delivery deterministic. 32 threads read a trapped page with a thread exception port set, which holds each EXC_BAD_ACCESS in the kernel. Once all 32 are held, the test resolves the page and then replies KERN_FAILURE to each, so every signal reaches the handler only after the page was given back. It passes on x86-64 under Rosetta 2 and on arm64, 10 times each. With the budget shared by all threads again, it fails with "32 read faults that arrive after their page was given back are not all retried".

The body now lists #13220 under Depends on.

AI-assisted: yes

@github-actions

github-actions Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Some comparisons could not be completed because a pull request conflicts with the base branch. Review these shared files manually before concluding that there is no overlap.

Pull request Shared files not compared
PR 5021 docs/dev/debt/libc.md
PR 6565 docs/dev/debt/libc.md
PR 7081 docs/dev/debt/libc.md
PR 7192 core/libs/prx/libc/include/GuestWriteWatch.hpp
core/libs/prx/libc/src/GuestWriteWatch.cpp
PR 7386 docs/dev/debt/libc.md
PR 7447 docs/dev/debt/libc.md
PR 7715 docs/dev/debt/libc.md
PR 8150 docs/dev/debt/libc.md
PR 8965 docs/dev/debt/libc.md
PR 9162 core/libs/tests/write_watch/CMakeLists.txt
PR 9340 docs/dev/debt/libc.md
PR 9371 docs/dev/debt/libc.md
PR 9447 docs/dev/debt/libc.md
PR 9526 docs/dev/debt/libc.md
PR 9929 docs/dev/debt/libc.md
PR 10113 docs/dev/debt/libc.md
PR 10324 docs/dev/debt/libc.md
PR 10349 docs/dev/debt/libc.md
PR 10405 docs/dev/debt/libc.md
PR 10729 docs/dev/debt/libc.md
PR 10953 docs/dev/debt/libc.md
PR 10989 docs/dev/debt/libc.md
PR 11172 docs/dev/debt/libc.md
PR 11262 docs/dev/debt/libc.md
PR 11266 docs/dev/debt/libc.md
PR 11332 docs/dev/debt/libc.md
PR 11624 core/libs/tests/write_watch/CMakeLists.txt
PR 11625 docs/dev/debt/libc.md
PR 11628 core/libs/tests/write_watch/CMakeLists.txt
PR 11661 core/libs/tests/write_watch/CMakeLists.txt
docs/dev/debt/libc.md
PR 11689 core/libs/tests/write_watch/CMakeLists.txt
docs/dev/debt/libc.md
PR 11698 core/libs/tests/write_watch/CMakeLists.txt
docs/dev/debt/libc.md
PR 11724 docs/dev/debt/libc.md
PR 11726 docs/dev/debt/libc.md
PR 11831 core/libs/tests/write_watch/CMakeLists.txt
docs/dev/debt/libc.md
PR 12299 core/libs/tests/write_watch/CMakeLists.txt
PR 12468 docs/dev/debt/libc.md
PR 12560 docs/dev/debt/libc.md
PR 12586 docs/dev/debt/libc.md
PR 12682 docs/dev/debt/libc.md
PR 12688 docs/dev/debt/libc.md
PR 12700 docs/dev/debt/libc.md
PR 12920 docs/dev/debt/libc.md
PR 12965 docs/dev/debt/libc.md
PR 13793 docs/dev/debt/libc.md
PR 13809 docs/dev/debt/libc.md
PR 14212 docs/dev/debt/libc.md
PR 14235 docs/dev/debt/libc.md

@oneandonlydean

Copy link
Copy Markdown
Collaborator

@Gonic0 The new per-thread key and 32-reader regression address the original delayed-reader failure. The 64-entry LRU cache still loses the persistent-fault bound under pressure: with 65 threads repeatedly faulting on one untrapped page in round-robin order, each thread evicts another entry before its next attempt, so every count resets to 1. A source model accepts all 2,600 deliveries across 40 rounds. Could you preserve the bound when entries are displaced and add a regression above the cache capacity, while retaining the legitimate delayed-reader test and truncated-backing negative control? This is a source scheduling finding, not a native macOS reproduction.

@Gonic0

Gonic0 commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Right: past 64 threads, the LRU table evicted each thread's entry before its next attempt. 034f633c11 also counts every untrapped retry between two untraps, and passes a fault on once the count passes 1,024 (64 entries × 16). A delayed reader needs one retry per untrap, so that leaves room for 1,024 of them. A fault the protection does not explain is still passed on after 16 retries on its own thread while its entry survives, and after 1,024 in all when entries keep being evicted.

The new read-trap-fault-bound mode is deterministic. 66 threads read a watched file mapping whose backing was truncated, each with a Mach exception port, and the test passes their faults on as signals strictly in turn for 40 rounds. With the total bound the child dies with SIGBUS. With the per-thread table alone it completes all 2,640 deliveries and fails with "66 threads that keep faulting on a truncated mapping in turn are retried without bound", on x86-64 under Rosetta 2 and on arm64. read-trap-late (32 delayed readers) and read-trap-fault still pass, 10 times each on both.

AI-assisted: yes

Gonic0 and others added 5 commits November 1, 2026 07:33
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@adriadam10
adriadam10 force-pushed the feat/libc-write-watch-read-trap branch from f476c01 to 21e65cc Compare November 1, 2026 06:35
@github-actions github-actions Bot removed the needs rebase Conflicts with main, rebase needed label Nov 1, 2026
@adriadam10
adriadam10 merged commit 5c9837b into boykopovar:main Nov 1, 2026
18 checks passed
@adriadam10

Copy link
Copy Markdown
Collaborator

Rebased onto current main, which gained the write-watch fault-ahead (54080056ff); resolved the conflicts in GuestWriteWatch.cpp, WriteWatch.cpp and the test list, keeping both sets of modes.

Added one commit: fault-ahead unprotected the following pages in record() and rewrote their state without ReadTrapped, which would have silently dropped a read trap on a neighbouring page. It now skips read-trapped pages (GuestWriteWatch.cpp:679). Not run on macOS; the write-watch CI jobs cover the rebase. Merged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants