Skip to content

fix(agc): run the unwatch hook before an import whose range leaves the write watch whatever the import does - #7505

Merged
oneandonlydean merged 11 commits into
boykopovar:mainfrom
AlxcBTW:pr/unwatch-hook-exits-v2
Oct 26, 2026
Merged

oneandonlydean merged 11 commits into
boykopovar:mainfrom
AlxcBTW:pr/unwatch-hook-exits-v2

Conversation

@AlxcBTW

@AlxcBTW AlxcBTW commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

What

ImportWatched (GuestMemory.cpp) runs the unwatch hook for the spans of a stays-watched import that leave the write watch, before the import when the page walk before it already fails and after it when only the walk after it fails, so pending storage-image results over those spans keep saved guest bytes. The branch also tests every exit of GuestMemory that has a hook.

#6308 (93df5c42a, LeandroLP23, the hook after the import) was closed unmerged as superseded by #8172, and the branch still carries its commit and its merge commit (48a1d3453, Dean Galvin). It no longer depends on #6308. Against current main (a6dcea927; git merge-tree is clean) the net change is 3 files, +92 -3:

  • core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp: in ImportWatched, for an import that stays watched, a span whose page walk fails before the import is taken out of the span list and given to the unwatch hook before the import runs; a span whose walk with the import window fails after the import is given to the hook there. main runs the hook in neither place: both spans leave the watch without it.
  • core/libs/prx/libSceAgcDriver/tests/WriteTracking.cpp: CheckUnwatchHooks in agc_write_tracking, for Unwatch (hook once, over the range, while it is watched), an import that does not stay watched (hook before the import), an import whose walk vouches for its pages (no hook, range stays watched) and, on Linux, an import whose range loses a block during the import (hook after it).
  • docs/dev/debt/libSceAgcDriver.md: the pending-results entry names a page walk that fails as an exit that runs the hook, and drops the sentence about an import fallback that unwatched without saving.

What main already covers, not repeated here:

What the branch still addresses: a span of a stays-watched import that is covered by the watch but whose page walk fails, before the import (the walk's walkWrites returns false) or after it (the walk with the import window returns false). Both paths drop the span from the watch on main without the hook, so pending storage-image results over it have no saved guest bytes and storing them throws. This is read from the code; no title run shows it on current main. The ProsperoPuzzles data in #6301 shows that, on current main, the refusal this chain used to cause no longer appears for that title.

Exits that still drop a range without the hook, not changed here:

  • Linux WatchMapping (DirectMemory.cpp): a second mapping of the same direct memory unregisters both aliases. The hook takes Pending().mutex and the GPU mutex, and the driver calls SharedBacking (takes g_directLock) from code that can hold the GPU mutex, so calling the hook from libkernel's map path could invert that lock order. Not shown to deadlock; not touched.

Tested

  • Linux (Ubuntu 24.04, Mesa lavapipe), fork CI run 39681814909 at a91b68d56 (the PR head bae91578f plus the CI scaffolding commit), ctest regex agc_write_tracking|agc_storage|agc_import|agc_host_import|agc_pending|agc_recorder|agc_driver_host_import|agc_unchanged|agc_collect|agc_partial|agc_resident: 34 of 34 passed, 2 of them skipped (agc_storage_clear_hand_over, agc_host_import_sparse), among them agc_write_tracking and agc_import_watch.
  • Windows (windows-latest, WinLibs GCC, no GPU), trimmed workflow, run 39681845571: 3 of 3 passed or skipped: agc_write_watch_coverage and agc_write_tracking pass, agc_import_watch is skipped (no Vulkan device).
  • Not shown: that CheckUnwatchHooks fails on main without the GuestMemory.cpp change. By reading, its last section (Linux, a block lost during the import) is the part main does not satisfy; no CI run of the test against main was made.
  • Not run on a title with this change. The Minecraft Dungeons II lab runs (PPSA16064, Windows, RTX 5070 Laptop, driver 617.42) used stacks that contain these commits; no row compared the hook with and without them.
  • Not run: macOS, an AMD or Intel GPU, any hardware GPU for the recorder tests (lavapipe only).
  • python3 tools/check_conventions.py --base origin/main on the branch: 0 errors; one warning (debt-length, the pending-results entry in docs/dev/debt/libSceAgcDriver.md, which was already over 1000 characters on main).

Checklist

LeandroLP23 and others added 5 commits October 15, 2026 12:43
… watch

An import that should stay watched still drops its range from the watch
when the range was only partly watched or its import window cannot be
resolved. That fallback skipped the unwatch hook, so a storage image with
results pending in the range lost the watch without its guest bytes
saved, and its next store threw "storing storage image results pending
over memory outside the write watch without the guest bytes saved before
them is not implemented".

Since boykopovar#6060 settled private imports on Windows take this path, and
ProsperoPuzzles (PPSA99006) stopped 5 s in (boykopovar#6301). The fallback now
releases the tracker lock, runs the hook over the page range, and
unwatches it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed range drops it

A storage image has results pending over a block whose neighbour block already left
the write watch. An import of both blocks that should stay watched cannot vouch for
its pages, so the range leaves the watch, and the image's next store has to find the
guest bytes the unwatch hook saved.

agc_storage_unwalked_import (Linux) runs that flow through HostImportFor. The host
import of the first creation is refused through the import budget so that the
import under test is the first one over the block. staleGenerationTests runs the
same flow for an import that does not stay watched; it now exists for both.
…lready fails

An import that should stay watched runs the unwatch hook only when its range
leaves the write watch, after the import. When the range is partly outside the watch
or the walk before the import fails, the range leaves the watch whatever the import
does, so the hook can run first, as it does for the import that does not stay
watched.

After the import, the walk that tells an import's own page marks from CPU writes
(the window compare) is skipped in this case, so the hook collects those marks as CPU
writes. A driver that marks the pages of an import written (the NVIDIA driver on
Windows does, see Pages under an import window) makes the hook keep the blocks they
cover for the CPU unless their bytes are proven unchanged, and those blocks do not
receive the GPU results. A failure of the walk after the import still runs the hook
after it.

The check of boykopovar#6308 also requires the hook to have run before the import.
…m the write watch

CheckUnwatchHooks runs Unwatch, an import that does not stay watched, an import whose
page walk vouches for its pages and, on Linux, an import whose range loses a block
during the import. The hook has to run once, over the dropped range, while the range
is still watched: before the import for the first two, after it for the last, and not
at all for the vouched import.
@github-actions

github-actions Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

Other open pull requests touch the same code. Merging one will break the other, or both implement the same thing. If this one builds on them, list them in Depends on.

Pull request Conflicting files (lines on main) Same exports Same new files
PR 10026 core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp L1828, L1831-1833
core/libs/prx/libSceAgcDriver/tests/WriteTracking.cpp L1720, L1950
docs/dev/debt/libSceAgcDriver.md L131
PR 10655 core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp L1822-1828

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 2434 docs/dev/debt/libSceAgcDriver.md
PR 4448 core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp
docs/dev/debt/libSceAgcDriver.md
PR 4449 docs/dev/debt/libSceAgcDriver.md
PR 5021 core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp
PR 5058 docs/dev/debt/libSceAgcDriver.md
PR 5372 docs/dev/debt/libSceAgcDriver.md
PR 5925 docs/dev/debt/libSceAgcDriver.md
PR 6187 core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp
PR 6188 core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp
PR 6192 core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp
PR 6194 docs/dev/debt/libSceAgcDriver.md
PR 6209 core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp
PR 6223 docs/dev/debt/libSceAgcDriver.md
PR 6273 core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp
core/libs/prx/libSceAgcDriver/tests/WriteTracking.cpp
docs/dev/debt/libSceAgcDriver.md
PR 6280 docs/dev/debt/libSceAgcDriver.md
PR 6300 docs/dev/debt/libSceAgcDriver.md
PR 6412 docs/dev/debt/libSceAgcDriver.md
PR 6481 docs/dev/debt/libSceAgcDriver.md
PR 6548 docs/dev/debt/libSceAgcDriver.md
PR 6549 docs/dev/debt/libSceAgcDriver.md
PR 6808 docs/dev/debt/libSceAgcDriver.md
PR 6960 core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp
PR 6977 docs/dev/debt/libSceAgcDriver.md
PR 7201 core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp
core/libs/prx/libSceAgcDriver/tests/WriteTracking.cpp
PR 7256 docs/dev/debt/libSceAgcDriver.md
PR 7642 core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp
docs/dev/debt/libSceAgcDriver.md
PR 7910 docs/dev/debt/libSceAgcDriver.md
PR 8014 docs/dev/debt/libSceAgcDriver.md
PR 8319 docs/dev/debt/libSceAgcDriver.md
PR 8520 docs/dev/debt/libSceAgcDriver.md
PR 8542 docs/dev/debt/libSceAgcDriver.md
PR 8592 docs/dev/debt/libSceAgcDriver.md
PR 9356 docs/dev/debt/libSceAgcDriver.md
PR 9387 docs/dev/debt/libSceAgcDriver.md
PR 9393 docs/dev/debt/libSceAgcDriver.md
PR 9402 docs/dev/debt/libSceAgcDriver.md
PR 9431 docs/dev/debt/libSceAgcDriver.md
PR 9432 docs/dev/debt/libSceAgcDriver.md
PR 9508 docs/dev/debt/libSceAgcDriver.md
PR 9533 docs/dev/debt/libSceAgcDriver.md
PR 9592 docs/dev/debt/libSceAgcDriver.md
PR 9672 docs/dev/debt/libSceAgcDriver.md
PR 9673 docs/dev/debt/libSceAgcDriver.md
PR 9674 docs/dev/debt/libSceAgcDriver.md
PR 9675 docs/dev/debt/libSceAgcDriver.md
PR 9676 docs/dev/debt/libSceAgcDriver.md
PR 9717 docs/dev/debt/libSceAgcDriver.md
PR 9742 docs/dev/debt/libSceAgcDriver.md
PR 9748 docs/dev/debt/libSceAgcDriver.md
PR 9842 docs/dev/debt/libSceAgcDriver.md
PR 9904 docs/dev/debt/libSceAgcDriver.md
PR 9905 docs/dev/debt/libSceAgcDriver.md
PR 9906 docs/dev/debt/libSceAgcDriver.md
PR 9950 docs/dev/debt/libSceAgcDriver.md
PR 10049 docs/dev/debt/libSceAgcDriver.md
PR 10193 core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp
docs/dev/debt/libSceAgcDriver.md
PR 10435 docs/dev/debt/libSceAgcDriver.md
PR 10760 docs/dev/debt/libSceAgcDriver.md
PR 11157 docs/dev/debt/libSceAgcDriver.md

@github-actions github-actions Bot added build docs agc libSceAgc* needs rebase Conflicts with main, rebase needed labels Oct 18, 2026
@akkadbakkad89

akkadbakkad89 commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Verified at head aaf19708f: agc_write_tracking plus the recorder import/storage sweep (agc_import_watch, agc_import_watch_other_family, agc_storage_*, agc_host_import_unmap, agc_host_import_retirement, agc_host_import_second_mapping) - 15/15 pass on NVIDIA RTX 3070 (driver 617.42) and Intel UHD 630 (ANYPS5_GPU=UHD, igvk64). Windows 11, MinGW (WinLibs GCC 15.2.0), Release.

@blawesom

Copy link
Copy Markdown
Contributor

Rebase handoff — rebased onto current main (all four commits, authorship unchanged). Test-call and docs-debt conflicts unioned with main's newer coverage.

Verified on Linux (CachyOS, kernel 7.2.9, GCC 16.2.1, Ninja, Release, BUILD_TESTING=ON, AMD RX 9070 XT / RADV): builds; conventions pass; full suite green apart from the two known main regressions (agc_driver_tessellation — Mesa, #5444; agc_driver_bda_device — #7584), neither from this PR.

To pick it up:

git fetch https://github.com/blawesom/AnyPS5 rebase/7505
git checkout <your branch>
git merge --ff-only FETCH_HEAD

Head is 9f770c6a5.

@github-actions github-actions Bot removed the needs rebase Conflicts with main, rebase needed label Oct 18, 2026
@AlxcBTW

AlxcBTW commented Oct 18, 2026

Copy link
Copy Markdown
Contributor Author

@blawesom thanks for the rebase handoff. I updated the branch with a merge of main instead of a rebase, so the history stays fast-forward (head 0b8940ec0). Your tree was my reference for the conflicts. It differed from mine in three places, in case they matter for other handoffs:

  • docs/dev/debt/libSceAgcDriver.md: the "Pages under an import window" paragraph is missing and the older Windows paragraph appears twice. The merge keeps main's newer paragraph plus "Pages under an import window".
  • CMakeLists.txt: main's srgb test registration, which landed after your rebase.
  • tests/WriteTracking.cpp: the call order is moved. I kept main's order.

Fork CI: Linux 717/717 (run 39146868385), Windows lab tests green (run 39146868399).

AI-assisted: yes (Claude Code)

@wachi35676

Copy link
Copy Markdown
Contributor

Tested at 0b8940ec0 on Windows 11 (GCC 15.2.0, Release), merged onto main 9f25d0bf5, on an RTX 5070 Ti (617.42) and an AMD Radeon iGPU, RDNA2 (1002:13c0, 32.0.21042.62, ANYPS5_GPU=AMD).

  • agc_write_tracking, agc_import_watch and agc_import_watch_other_family pass on both.
  • With c20686e11's GuestMemory.cpp hunk reversed, agc_write_tracking fails on both GPUs: an import over a partly unwatched range ran the unwatch hook after the import.
  • In the 724-test agc_|shader|recompiler set, the only new failure was a 30 s timeout of agc_driver_guest_memory_lifetime_heap on NVIDIA, which passes when rerun alone.

agc_storage_unwalked_import is registered for Linux only and wasn't run here.

AI-assisted: yes (Claude Code); results above were run on this machine.

@oneandonlydean

Copy link
Copy Markdown
Collaborator

@AlxcBTW thanks for this. #8172 has merged and #6308 is closed as superseded, so this branch no longer fits main. Main's ImportWatched now splits a partly watched staysWatched import into spans of covered 64 KiB blocks, keeps those spans watched, and unwatches only the gaps and any span whose walk fails. The single-range fallback that #6308 and c20686e edit is gone. Your two commits are written on top of #6308's lines, so dropping #6308's commit won't leave a working change.

The gap you closed is still on main, though. The staysWatched path never calls beforeUnwatch, and two of its exits take watched blocks out of the watch:

  1. A covered span whose CPU walk fails before the import is dropped by the erase_if just ahead of runImport(). It then falls inside the unwatchLocked gap ranges after the import. This is your save-early case: the span will leave the watch whatever the import does, so the hook can run before the import, and the import's own page marks aren't collected as CPU writes.
  2. A span whose window walk fails after the import (continue in the span loop) is unwatched by the next unwatchLocked with no hook. @LeandroLP23 offered to cover this path in fix(agc): run the unwatch hook when a watched import leaves the write watch #6308's closing comment.

Could you rework this PR onto current main as the single fix for both? (Please agree with @LeandroLP23 so only one PR carries it.)

  • Before runImport(), collect the spans the erase_if drops, release the tracker lock, run beforeUnwatch over them, and relock. Those spans are unwatched in any case, and the surviving spans are walked again against the window after the import, so a tracker change while the lock is released is harmless.
  • After the import, collect the spans whose window walk fails and run beforeUnwatch over them, again with the lock released, before they're unwatched. Like MarkWritten: collect, hook outside the lock, then unwatch.
  • Make sure each span gets the hook exactly once, and that gaps that were already unwatched don't get it.
  • Tests under fix(agc): keep resident DCC targets watched under aligned block imports #8172's semantics, where a partly watched import keeps its watched blocks watched: make a covered span fail its walk before the import (and, separately, inside the import window), and check that the hook ran over that span before it left the watch, and only once. Keep the check that a vouched span gets no hook and stays watched.

As far as we know, no current title reaches these two exits: the reports in #6301 run on main with #8172. So this is a correctness fix for a rare path. If it's reached today, the result is the explicit "results pending ... without the guest bytes saved" refusal, not wrong output.

@github-actions github-actions Bot removed the needs rebase Conflicts with main, rebase needed label Oct 21, 2026
@wachi35676

Copy link
Copy Markdown
Contributor

Re-checked head bae91578fe against main 5b85225ef on Windows 11 (WinLibs GCC 15.2.0, RTX 5070 Ti and the AMD RDNA2 iGPU), plus WSL Ubuntu 24.04.

It no longer builds on main. GuestMemory.cpp conflicts in the span loop, where main added the GuestArenaCompareResidentWhenShared_nid_no_patch call after the window walk. Once that is resolved, saveBytes fails to compile, because 2df7e993d turned the tracker lock into a TrackerLock class:

GuestMemory.cpp:1829:14: error: 'class AgcDriver::GuestMemory::{anonymous}::TrackerLock' has no member named 'unlock'; did you mean 'Unlock'?

Main releases the lock around its own beforeUnwatch call with lock.Suspend() / lock.Resume(). With those, it builds, and agc_write_tracking, _metadata_commit, _rewatch_import_pages, agc_import_watch and _other_family pass 3/3 on both GPUs.

On Windows, though, agc_write_tracking also passes with main's GuestMemory.cpp. The CheckUnwatchHooks part that runs there (Unwatch, a non-staying import, a vouched import) covers paths main already handles, and the walk-fails-after-import case is #ifndef _WIN32. In WSL the test skips (no userfaultfd write protection), so I couldn't run the discriminating case. A Windows variant of the walk-fails case, or a Linux run on a kernel with uffd-wp, would show the fix.

AI-assisted: yes (Claude Code); results above were run on this machine.

@oneandonlydean

Copy link
Copy Markdown
Collaborator

@AlxcBTW this had fallen behind main, so I merged main into the branch (72598e3). main has since changed the tracker lock to a TrackerLock wrapper, so in a small follow-up commit (d84ce7c) saveBytes now uses its Suspend()/Resume(), which release and retake the same lock as the old unlock()/lock() calls did. Your logic is otherwise unchanged.

On my earlier request for a regression where the first walk fails before the import: that's no longer required for this PR. We'll treat it as a follow-up, so you don't need to add it here. Next is CI on this head and the game gate.

@github-actions github-actions Bot removed the needs rebase Conflicts with main, rebase needed label Oct 26, 2026
@wachi35676

Copy link
Copy Markdown
Contributor

Re-checked head d84ce7c11c merged onto main 33ae5a314 (clean). Windows 11, WinLibs GCC 15.2.0, Release, RTX 5070 Ti and the AMD RDNA2 iGPU.

  • It builds on current main now that saveBytes uses lock.Suspend() / lock.Resume(). agc_write_tracking, _metadata_commit, _rewatch_import_pages, agc_import_watch and _other_family pass 2/2 on both GPUs.
  • As before, the walk-fails-after-the-import case in CheckUnwatchHooks is #ifndef _WIN32, so on Windows the test passes with main's GuestMemory.cpp too. In WSL it skips (no userfaultfd write protection), so I still couldn't run the case that tells the two apart.
  • check_conventions.py gives one warning: the Pending storage-image results over memory outside the write watch entry this PR edits is 1,352 characters. On main it is 1,378 characters (the PR even shortens it), and the check flags it only because the line changed.

AI-assisted: yes (Claude Code); results above were run on this machine.

@oneandonlydean
oneandonlydean merged commit b27cc84 into boykopovar:main Oct 26, 2026
10 checks passed
@winstrex

Copy link
Copy Markdown
Contributor

@oneandonlydean, a heads-up on a race this PR brought into main.

In ImportWatched, when a span's window walk fails after the import, saveBytes suspends the tracker lock and runs the unwatch hook inside the loop over the window walks. Later spans haven't had their window walk yet. While the lock is down, another thread can collect one of those later spans without the window, and every page the import touched there gets a CPU-write stamp. That is the race @ahmadmuj found in #10026 (review). Before this PR, the lock stayed held from the import through every window walk.

#10026 fixes it: every window walk finishes first, then the failed spans' hooks run under one suspension, before any unwatchLocked. CheckImportWalkHookKeepsLaterWindows in agc_write_tracking reproduces the race deterministically: with the hook moved back into the loop, it fails, as @ahmadmuj checked in his second review.

#10026 waits on #7048, so if you'd rather not leave main with the race until then, I can open a small separate PR against main with only this fix and that test.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants