WIP: fix(macos): pin Vulkan host imports until their last owner, and unwind failed startups #491
No reviewers
Labels
No labels
area/accessibility
area/audio
area/input
area/install
area/menu
area/renderer
area/saves
area/upscaling
good first issue
gpu/amd
gpu/igpu
gpu/intel
gpu/nvidia
help wanted
kind
bug
kind
chore
kind
docs
kind
feature
kind
performance
kind
question
needs measurements
platform/linux
platform/steam-deck
platform/windows
preview
priority
critical
priority
high
priority
low
status
blocked
status
confirmed
status
duplicate
status
invalid
status
needs-info
status
wontfix
testing wanted
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
MagicBeanDev/Longshot!491
Loading…
Reference in a new issue
No description provided.
Delete branch "mooki/Longshot:mac/renderer-correctness"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What this is for: the macOS port (#420). On macOS the renderer imports guest memory into Vulkan, and the guest
pages behind an import must not be replaced or discarded while any Vulkan object still uses them. This PR has every
macOS import pin its whole backing in the executable (through two new host callbacks) until the last Vulkan owner is
destroyed, and makes imports move-only so an owner is never copied or dropped early. It also makes a renderer whose
startup fails part-way give back what it made, and drains the recorder before that rollback destroys the allocator.
Those startup and ownership changes apply on every platform; they are listed under "Changes on Windows and
Linux". Shader caches: this PR edits
gpu/vk/vk_api.h. On5e072fc8vk_api.his stamped, so every platform'scaches rebuild once; after #370 it is not, so they do not. What is measured: two new game-free tests compile the real import, retirement, startup and
audio code against recording stand-ins, on arm64 and on x86_64 under Rosetta. They fail on
main, and onmainwiththe pull requests this one carries, and pass here, two interleaved runs each. Five existing checks that pass on
mainand that this PR's first commit broke are shown failing before their repair and passing at the head. And the full game-free suite on an Apple
M4 Max (macOS 26.7.1), compared by test name with
main. Not measured: a game run on this branch; a real Vulkandevice; Windows and Linux runs; macOS 14.4; an Intel Mac.
Depends on #438 (vk-admission) and #480 (guest-16k), which carries #478 (guest-memory) and #436 (fault-contexts). This
branch carries their commits unchanged: guest-16k's head
647e90e4, then #438 merged (91d8cc35); its own commitsstart at
107fda29. It needs guest-16k for the executable'shos_posix_guest_import_pin/unpin, which the newcallbacks call, and #438 for
MkwVkHost's Apple callbacks it appends to.Why
guest-memory code can replace a page's backing (a discard maps fresh memory there) while the renderer still has a
buffer over it; the GPU would then read pages the game no longer sees. Nothing told the executable which ranges the
renderer was importing, or for how long.
Poolwas copied into the import table and the background-import queue(
imported.emplace(lo, p),bg_ready.push_back({.., p})). Any owner the copy carried would be released twice, or bythe wrong copy.
replay_startreturnedfalseon a failed create and left behind what it had made (fences,command pools, semaphores, the parallel init pools, the VMA allocator). Several creates were not checked at all
(
vkAllocateCommandBuffers, the frame fence and semaphore).waveOutOpenreached SDL through a weakhos_sdl_audio_openimport, which astrict two-level macOS link (
-undefined error) refuses.MessageBoxAlooked for SDL under its Linux names.What changed
macOS (inside
#if defined(__APPLE__))gpu/vk/vk_api.h:MkwVkHostgainsguest_import_pin(lo, hi)andguest_import_unpin(lo, hi)at its end, onApple only, where
MKWVK_API_VERSIONbecomes 23 (22 after #438). Elsewhere it stays 21 and the structure isunchanged.
host/rx_backend.cfills them with guest-16k'shos_posix_guest_import_pin/unpin.gpu/vk/vk_memory.cpp: each macOS import has oneDarwinImportBacking: the whole outward-rounded range, pinned inthe executable before the import is prepared and unpinned only in its destructor, after its device memory is freed.
A
Pool's buffer is a move-onlyDarwinImportViewthat holds the backing; the buffer is always destroyed before thelast backing owner unpins. Retirement keeps the existing completion gates (
retired_value,dead_imports): a viewleaves only when the GPU is past it. A failed or unpublished import unwinds its view and pin at once. The published
tables live in process-owned containers that are never destroyed at exit, as the recorder's are, so no Vulkan call
runs from static teardown.
darwin_guest_importedreports which guest ranges have live owners, from the existingtables (no second registry).
gpu/vk/darwin_import.h(new) holds the range arithmetic.host/posix/win32_posix.c:waveOutOpenresolves the executable'shos_posix_audio_openonce throughdlsym(RTLD_MAIN_ONLY, …)instead of a weak import, so the renderer image links strictly and never starts a secondSDL.
host/hos_sdl.cexports that service.MessageBoxAlooks forlibSDL3.0.dylibandlibSDL2-2.0.0.dylib.Changes on Windows and Linux
gpu/vk/vk_replay.cpp,gpu/vk/vk_vma.cpp, every platform): every fallible create inreplay_startis checked, and a failure calls the newreplay_start_rollback(), which destroys the parallel initpools, each recording's fence and command pools, each frame's command pool, fence and semaphore, the three timeline
semaphores, and then the VMA allocator (
vma_startup_rollback()), before returningfalseas before.vma_initpublishes the allocator only once
vmaCreateAllocatorsucceeded. A startup that succeeds runs exactly as before.gpu/vk/vk_recorder.h,vk_work.h, every platform):vmaDestroyAllocatoris a drain-firstboundary, next to main's
vmaFreeMemory, so withRX_VK_RECORDER=deferorthreadthe recorder drains before therollback destroys the allocator. With the recorder off (the default) the boundary is a relaxed load and a branch.
tests/test_recorder_sync.pycaught the missing boundary.gpu/vk/vk_memory.cpp, every platform):Poolvalues are moved, not copied, into the importtable and the background queue; the queue slot for a retired or evicted pool (and a
DeadImport) is allocatedbefore the pool leaves the live table, so a failed allocation leaves its owner where it was; the three-line buffer and
memory release is one
destroy_import()helper with the same calls in the same order. Log lines read the moved pool.host/posix/win32_posix.c(Linux):MessageBoxAcloses the extra reference itsRTLD_NOLOADdlopentakes;waveOutOpenchecks itscallocandpthread_mutex_initand returnsMMSYSERR_ERRORinstead of crashing.5e072fc8vk_api.his in the stamp, so every platform retranslates once; after #370 it is not,and nothing rebuilds. Its preprocessed text off Apple is unchanged and the API stays 21.
How this was checked: every changed C/C++ file, preprocessed with
unifdeffor Linux (-U__APPLE__ -D__linux__) andfor Windows (
-U__APPLE__ -D_WIN32), compared with the branch before this PR's commits.vk_api.h,hos_sdl.handrx_backend.care identical; the differences in the others are exactly the ones listed (hos_sdl.cby one blank line).That is text, not a Windows or Linux build.
Rebase onto
mainvmaFreeMemoryboundary and this PR'svmaDestroyAllocatorare both kept, inRX_REC_BOUNDARIESandvk_work.h; itsvk_memory.cppincludes and this PR's are both kept. The renderer fixturegains an empty
sparse_wantedstand-in, which #250'sretire_poolnow erases from.imports_give_backends; the cut follows the new words. Samecode cut. Both in commit
91f9a58d.Tests
tests/test_darwin_renderer.py(new) withdarwin_import_lifetime_test.cppanddarwin_renderer_startup_test.cpp:cuts the real import, view, pin, retirement and eviction code and the real
replay_start, its rollback,vma_initandvma_startup_rollbackfrom the source and runs them against recording stand-ins for Vulkan and the host pins. Itchecks that every pin has one unpin after the buffer's destruction, that retirement waits for completion, that each
failure point unwinds exactly what was made, the host callback table's positions, and
MessageBoxAon bothplatforms.
--legacy-sourcerunsmain's import and retirement topology through the same consumer.tests/test_darwin_audio_service.py(new): links the real DarwinwaveOutshim strictly (-undefined error) andchecks it reaches only the executable's audio service, never a renderer-local lookalike, with every
MMRESULTpath.test_vk_darwin_admission.py(Apple API 23, the two callbacks at 368 and 376, size 384),test_parallel_init_order.py(the init pools are made last and destroyed by the rollback),test_sdl_audio_library.py,test_import_buffer_binding.pyandtest_texel_buffer_ownership.py(they keep testing the Windows/Linux factories ona Mac; the Apple owner has its own test), and
test_direct_buffer_writes.py,test_parallel_rb_refresh.pyandnx2/test_import_windows.py(they cutPool's andimport_pool's Windows/Linux layout, as before), andtest_mirror_gpu_only.py(its cut ofpool_lookupnow ends at the movedimported.emplace(p.lo, std::move(p))).Flags and settings
None new.
MKWVK_API_VERSIONbecomes 23 on macOS only; 21 elsewhere.Measurements
main:
5e072fc8c2adadba5ea610148be1c47800ba0cca(2026-11-14)this PR:
mac/renderer-correctness@c1f9f884995db23c3d1620f75bab97ea67f19478deps:
b5da98ba,mainplus the pull requests this one carries (the branch just before its own commits)Hardware: Apple M4 Max, 16 cores, 64 GiB, macOS 26.7.1; Apple clang 21.0.0; Vulkan-Headers 1.4.345; Python 3.14.10. No
GPU, no game.
Scene: none. Game-free.
Method: the two new tests with
--arch arm64 --arch x86_64, on this PR, onmainand on deps (through--source),interleaved: PR, main, deps, PR, main, deps. Then the fixtures at each of this PR's commits (
git archive), throughtests/run_all.py. Every run throughtools/slot.py; x86_64 executables under Rosetta.How to reproduce:
python -I -B tests/test_darwin_renderer.py --cxx clang++ --headers <Vulkan-Headers/include> --arch arm64 --arch x86_64 [--source <tree>];python -I -B tests/test_darwin_audio_service.py --cc clang --arch arm64 --arch x86_64 [--source <tree>].5e072fc8b5da98bac1f9f884tests/test_darwin_renderer.py, arm64 + x86_64, runs 1 and 2 (and 3, at the head)struct DarwinImportBackingto cuttests/test_darwin_audio_service.py, arm64 + x86_64, runs 1 and 2 (and 3, at the head)WaveAudioOpenFnservice route to cuttests/test_mirror_gpu_only.py91f9a58d, before its last commit:substring not found)Runs 1 and 2 of this PR's side were at
91f9a58d, which differs from the head only intests/test_mirror_gpu_only.py;run 3 is at the head.
This PR's own commits (
git archiveof each, the same 11 checks throughtests/run_all.py):107fda29(the change)1f805dac(+ fixtures)05213268(+ boundary)91f9a58d(+ fixture follows main)test_direct_buffer_writes,test_parallel_rb_refreshunknown type name 'DarwinImportView'nx2/test_import_windowsdarwin_import_poolundeclaredtest_recorder_syncvk_vma.cpp calls ['vmaDestroyAllocator'], which vk_work.h does not drain beforetest_darwin_rendererThe checks in the first three rows pass on
main: this PR's first commit broke them, and its next two commits repairthem.
91f9a58dmakes the new fixture followmain. The full suite at91f9a58dthen found one more check the firstcommit broke,
tests/test_mirror_gpu_only.py(it passes onmain); the last commit,c1f9f884, repairs it. Withthe merge's
gpu_onlymark removed from a throwaway copy, that test fails (a pool that takes others in does not keep their gpu_only and gpu_written marks), so it still checks what it did.Testing
Tested:
The two new tests and the per-commit checks, as above.
Mutants (throwaway copies of this PR):
vk_work.hwithout thevmaDestroyAllocatorboundary makestests/test_recorder_sync.pyfail (vk_work.h's boundary macros and RX_REC_BOUNDARIES differ: … only in vk_recorder.h ['vmaDestroyAllocator']);vma_startup_rollbackthat leaves the allocator alive makestests/test_darwin_renderer.pyfail (FAIL: failed startup leaked a resource).The Windows and Linux text, as described under "Changes on Windows and Linux" (
unifdef).The whole suite,
tests/run_all.py --jobs 6, on the same Mac:tests/test_darwin_renderer.py(new) passes;tests/test_darwin_audio_service.py(new) is skipped, because without the run-all PR (#454) the runner labels macOS
linux(it passes run directly,above).
test_darwin_fault_context,test_darwin_guest_memory,test_vk_darwin_admissionandtest_vk_darwin_sdl(new) pass;test_collect_widegoes from FAIL to pass.mainfailed here:tests/test_gpu_hang_watch.py, a timing check (exit 3, 3.5 sagainst its bound of 3.4 s) on a loaded machine (load average 10 to 16). The code it cuts,
gpu_hang_watch_start,is the same here as on
main, byte for byte. Of two reruns of it alone at this head, the first failed the same wayand the second passed.
maintoo:test_appimage_package_retry,test_control,test_framegen_resources,test_game_layout,test_low_memory_notice,test_mem_gate,test_mem_gate_children,test_mirror_trim,test_module_ids,test_oot_state_tap,test_pad_layout,test_pcache_header_read,test_pipeline_cache_file,test_pipeline_cache_save,test_pipeline_play_cache,test_prof_symbols,test_program_cache_cleanup,test_program_cache_one_variant.History, not rerun on this branch: these commits were first made on the macOS integration branch (
044635ea,6cf83191,605ddf99) with the same checks; integration builds that carry them have since built and started the gameon macOS (see #420). That speaks for those builds, not for this branch alone.
Not tested:
fail), and the recorder modes (
RX_VK_RECORDER=defer/thread) through such a failure.Needs other testers:
5e072fc8the shader caches rebuild oncefirst). Then a
forced startup failure (for example a renderer started with too little device memory, or a driver that refuses a
fence), which must log the failure and fall back as before, with no leak or crash on exit (request to open).
(request to open).
Screenshots
Nothing on screen.
Risk and rollback
5e072fc8vk_api.his hashed intoLONGSHOT_SHADER_STAMP(nx2/build.py:1176-1182), so thefirst start after updating retranslates the shader programs; after #370 it is not hashed and nothing is retranslated.
The rollback runs only after a failed startup, and the moves and
queue-slot order keep the same owners and the same release calls. The new recorder boundary costs a relaxed load and
a branch with the recorder off.
MessageBoxAnow drops its extra SDL reference;waveOutOpenfails cleanly where it would have crashed.VK_ERROR_MEMORY_MAP_FAILED) rather than importingunprotected pages.
107fda29..c1f9f884).Reviewer notes
b5da98ba..c1f9f884(five commits); the commits before it are the pull requests this one depends on.107fda29is the change (one conflict with #250's includes, kept both);1f805dacmoves three fixtures onto theWindows/Linux
Poollayout;05213268adds thevmaDestroyAllocatorboundary (beside #250'svmaFreeMemory);91f9a58dfollows #324 and #250 in the new fixture;c1f9f884follows the movedPoolintest_mirror_gpu_only.py.The renderer-core PR later rewrites that same line for its
imported_add.tests/test_darwin_audio_service.pydeclares a skip for Windows and Linux. Without the run-all PR (#454), the runnerlabels macOS as
linuxand skips it inrun_all.py; run directly, or with #454, it runs and passes (above).DarwinImportBackingandDarwinImportViewinvk_memory.cpp, thenreplay_start_rollbackinvk_replay.cpp.mainatdc390e59(7 commits after this branch's base),git merge-treefinds conflicts ingpu/vk/vk_api.handhost/rx_backend.c. The branch before this PR's commits (#438) meets the same two; a rebasebefore ready resolves them.
Issues
See #420.
Checklist
For everyone
type(scope): description).main. Based on5e072fc8, with the pull requests it depends on merged in;mainhas sincemoved to
dc390e59(conflicts named under Reviewer notes). Rebased before ready.python -I -B tests/run_all.pyrun and passing (any failure that also fails onmainis named above). On macOS:530 passed, 18 skipped, 18 failures that also fail on
main, andtest_gpu_hang_watch, a timing check that failedunder load here and passed on a rerun (named under Testing).
requests are still to open.
the median reported. For a
fix: the new tests fail onmainand pass here, in two interleaved runs each.third_party/untouched (a new folder with aPROVENANCE.mdinstead).If applicable
Assisted-by:/Co-Authored-By:trailers.MKWVK_API_VERSIONbumped (gpu/vk/vk_api.h'sMkwVkHostorMkwVkRendererchanged). 23 on Apple, whereMkwVkHostchanged; 21 elsewhere, where it did not.mainand passing here.tests/that reads changed source updated in this PR, not weakened.044635ea added vma_startup_rollback (vk_vma.cpp, run after a failed replay_start), which calls vmaDestroyAllocator. vk_vma.cpp includes vk_work.h last so that RX_VK_RECORDER=defer/thread drains before its destructions, and every such boundary call in a vk_work.h file must be in RX_REC_BOUNDARIES with a drain-first macro. This one was not, and test_recorder_sync caught it ("vk_vma.cpp calls ['vmaDestroyAllocator'], which vk_work.h does not drain before"): it passes at 58c5c5aa (044635ea's parent) and fails at 044635ea. Add it to the list and to vk_work.h, next to vmaDestroyBuffer and vmaDestroyImage. With the recorder off (the default) boundary() is a relaxed load and a branch. Rollback runs before any image or buffer exists, so nothing should be held there today, but the allocator is no longer destroyed while a recorder could still hold packets. The test is unchanged (sources: 42 boundaries drained, was 41). Assisted-by: Claude Opus 5.5 Co-Authored-By: Claude Opus 5.5 <[email protected]>View command line instructions
Manual merge helper
Use this merge commit message when completing the merge manually.
Checkout
From your project repository, check out a new branch and test the changes.