Shim waits for the panel to release a buffer before the guest renders into it
Problem
A user reports tearing in DXVK games under gamescope on a Mangmi Air Y Pro (SD865, 1280x960 at 60 Hz): Skyrim LE, Arkham City, L4D and NFS Most Wanted 2012. It only shows above about 40 fps, with vsync on or off, and screen recordings don't capture it. That points at scan-out, not at the frames themselves.
Mechanism
On direct scan-out the guest's pool buffer goes straight to the panel, and the guest gets that buffer back before the panel has finished reading it:
- The daemon publishes the buffer and sends gamescope's page-flip event at publish time, not when SurfaceFlinger shows the frame (
modules/gndrm-daemon/src/lib.rs:2305,commit_worker_loop). - gamescope's
page_flip_handlerthen releases the buffer that flip replaced (wl_buffer.release). - The shim frees that slot (
icd_shim/src/proxy.rs:8065,on_buffer_release), and the game's nextvkAcquireNextImageKHRhands it out right away. - The game renders into that buffer while SurfaceFlinger may still be scanning it out, until the replacement is actually on screen. The daemon does wait on the release fence (
modules/gndrm-android/src/asc.rs:1467), but that wait only guards the daemon's own next publish of the slot, not the game's writes into it.
The shim already waits for the game's render fence before it commits, so a frame that is still rendering is not the cause.
Why not a daemon-only fix
- Holding the flip event until SurfaceFlinger releases the buffer was measured to halve fps above half the panel rate. gamescope's commit is synchronous, so a held event becomes held frames.
- GameNative/gamescope#25 together with GameNative/gamenative-drm#40 avoid that cost, but they need a gamescope change. If this PR lands, both are superseded for this bug.
The fix
The flip event keeps its timing. The release goes to the process that renders into the buffer:
- Wire (
modules/gndrm-uapi/src/wire.rs): a newReleaseFencenote ("GNRF") on the existing scan-out channel. It is only sent to connections that declaredCAP_RELEASE_FENCE(bit 3). There are two kinds:- a pending note: buffer
inowas replaced on the layer, and its fence will follow; - a final note: carries SurfaceFlinger's release fence as a sync file over
SCM_RIGHTS, or no fd when SurfaceFlinger reported none (the buffer is already free).
- a pending note: buffer
- Daemon (
modules/gndrm-android/src/asc.rs):- Each show transaction queues the inode of the buffer it replaces on its registration.
build_show(:3607) sends the pending note before the apply, so it is on the shim's socket before the flip event leaves. on_complete(:414) pops that entry and forwards theASurfaceTransactionStats_getPreviousReleaseFenceFdit already queries. The fd is borrowed (pushes::push_with_fd,modules/gndrm-daemon/src/pushes.rs:133), so the daemon's own wait still gets its copy.
- Each show transaction queues the inode of the buffer it replaces on its registration.
- Shim (
icd_shim/src/scanout.rs):- It declares
CAP_RELEASE_FENCEand keeps notes only for buffers it was handed (record_release,:850). - When
vkAcquireNextImageKHRhands out a slot (icd_shim/src/proxy.rs:2847),wait_release(:976) waits for that slot's release: first for a note still on its way, then for the fence. The wait is bounded by 33 ms or the acquire's own timeout, and atimeout == 0acquire never blocks. - Each fence is waited at most once and then closed. A fence that arrives after its wait ran out is dropped. Timeouts are counted, and the first one is logged.
poll_notesnow drains every queued frame and receives descriptors.
- It declares
Why the wait is on the CPU and not imported into the acquire semaphore/fence. The release fence only exists once the transaction that replaced the buffer completes, and that is normally after gamescope released the buffer, so often after the acquire. Binary semaphores can't be waited on before their signal is submitted, and the shim signals the guest's semaphore/fence with an empty submit inside the acquire. So a fence that hasn't arrived yet has to be waited for on the CPU anyway. When it does arrive (SurfaceFlinger's completion fires after the replacement is presented), it has usually already signalled, so the poll returns immediately. An import would also need VK_KHR_external_semaphore_fd enabled on the game's device, which the shim does not control.
Compatibility. An old daemon sends no notes, so the shim behaves as on master. An old shim never subscribes, so the daemon sends it nothing.
What is unchanged
- The page-flip event timing, and the daemon's internal release-fence wait.
- The foreign-copy path, the cursor plane and frame generation.
- The GL / Xwayland path is not covered: those producers don't read these notes.
Test plan
Host tests ran in rust:1-bookworm on aarch64. The crates use Linux libc, so they don't build on macOS.
-
cargo test -p gndrm-uapi --lib: 20 passed. Covers the GNRF round-trip and rejection of other frames. (abi_ground_truthneeds the Linux C headers; it fails the same way on master on a macOS host.) -
cargo test -p gndrm-daemon: 100 + 8 + 36 passed.push_with_fdsends the fd only toCAP_RELEASE_FENCEsinks, and each sink gets its own duplicate. -
cargo test -p adrenotools_gamescope --lib: 130 passed, including these new cases:- a fence is waited once and closed;
- the acquire waits for a fence still on its way;
- an unsignalled fence is bounded;
- a lost fence costs one bounded wait, and a late fence is dropped;
- a release with no fence frees the slot;
- notes for other buffers are ignored;
- a pushed fd is received.
-
cargo clippy --target aarch64-linux-android -p gndrm-android -p adrenotools_gamescope -p gndrm-daemon -p gndrm-uapi --all-targets -- -D warnings: clean (77c581d fixes adoc_markdownlint). - Android release build, using the NDK 27.3 / rustc 1.98.1 rustflags from
tools/build-android.shwith the darwin NDK linker.check-exportsPASS (daemon 20 exports, shim 3) andcheck-elf-depsOK for both libraries. Thegn-utilsprobes don't link against the local prefix'slibvulkan; they don't ship and this PR doesn't touch them. 9e79a84 and 77c581d build byte-identical libraries:libgamenative_drm.so: sha25628d5d474273052cf24adf64b353139da2ee9c4c4ebf8b522c87427c7e85493c3, md5ece0bab966db488fec6876515194f3d6libadrenotools_gamescope.so: sha2562dcb1948521f82ed59ab254e6866d3c1fa124dca07354b8ec17701f347cc4ce6
- Delivery: utkarshdalal/GameNative#2369 ships the daemon in jniLibs and the shim as payload
gamescope-gamenative-20261107.1.tzst(sha256a14c58fe…19cf). It must not merge before this PR. - Device run: pending. Expected signal: an uncapped DXVK game on a 60 Hz panel (one of the reported titles) stays tear-free at the same fps as master. On master a cap below the panel rate already hides the tear, so the comparison has to be uncapped.