gamescope +pfhdr7 — linger no longer dies of its own capture teardown (luxus's fix, overlay#9) #212

Merged
enricobuehler merged 1 commits from worktree-gamescope-linger-pw-destroy-race into main 2026-08-13 22:15:41 +00:00
4 changed files with 201 additions and 1 deletions
+19
View File
@@ -14,6 +14,25 @@ with the version table of the release you are moving to, then read **Breaking ch
## v0.28.1 — in development
### punktfunk-gamescope `+pfhdr7` — a lingered session no longer dies of its own capture teardown
🛑 **On client disconnect the host keeps the headless gamescope alive so a reconnect resumes the
same session — and gamescope could SIGSEGV in exactly that window, so the kept display was dead and
reconnect silently got a fresh compositor with the game lost.** When the capture consumer leaves,
PipeWire's `remove_buffer` (and the stale-push path in `dispatch_nudge`) destroyed idle buffers on
the **PipeWire thread**; dropping the last `CVulkanTexture` reference there calls into the Vulkan
driver (`vkDestroyImage`/`FreeMemory`/dmabuf fds) while steamcompmgr can still be inside
`vulkan_screenshot` on another buffer of the same 4-buffer pool. On NVIDIA that races to a SIGSEGV
in `CVulkanCmdBuffer::insertBarrier` — timed at stream end, which is why it selectively killed
linger. The journal signature: linger line → coredump → `kept display was dead — recreating`.
Patch 0009 queues those corpses on the PipeWire thread and has steamcompmgr reap them on every
vblank — including while the stream is paused, which is precisely the linger state. Found, fixed
and proven live by **luxus** ([punktfunk-overlay#9](https://github.com/luxus/punktfunk-overlay/issues/9)):
four coredumps on 4K60 HDR + composited cursor, zero after; disconnect/reconnect now reuses the
lingered session. Banner `+pfhdr6``+pfhdr7` (no new capability — but "reconnect lost my game"
triage must be able to read a box's exposure off its banner, the same rule as `+pfhdr5`/`6`).
### NixOS — the plugin runner was installed, running, and reported missing
🛑 **On NixOS every plugin *package* op failed with "the plugin runner isn't installed", on a box
+7 -1
View File
@@ -19,7 +19,7 @@ pkgname=punktfunk-gamescope
# bump it with the marker so pacman sees a new version when only our patches moved.
_gsver=3.16.25
_gsrev=5fb8dce4a09d0a68d097b9faf9513782106bc843
pkgver="${_gsver}.pfhdr6"
pkgver="${_gsver}.pfhdr7"
# 2: patch 0006 (never destroy the Vulkan device/output at exit). No capability moved, so the
# `.pfhdrN` level deliberately stays put — see README.md.
# 3: pin moved 8c676c39 -> 5fb8dce4 (3.16.25-1 -> 3.16.25-11), which brings upstream's own
@@ -39,6 +39,12 @@ pkgver="${_gsver}.pfhdr6"
# the composite (and the stream) black while every health signal stayed green. No capability the
# host probes for, but a field box's banner has to distinguish a build that can lose its composite
# this way from one that cannot.
#
# pfhdr7 / rel 1: patch 0009 (reported + written by luxus, punktfunk-overlay#9) moves capture-buffer
# destruction off the PipeWire thread: remove_buffer used to drop the last CVulkanTexture ref there,
# racing steamcompmgr's vulkan_screenshot on the same device — a SIGSEGV precisely in the linger
# window, so a kept display was dead and reconnect lost the game session. No capability the host
# probes for, but "reconnect lost my game" triage has to read the difference off the banner.
pkgrel=1
pkgdesc="gamescope with 10-bit BT.2020/PQ PipeWire capture, for punktfunk HDR streaming"
arch=('x86_64' 'aarch64')
+16
View File
@@ -19,6 +19,7 @@ The patches here add the missing half, and nothing else. See
| `0006-punktfunk-never-destroy-the-Vulkan-device-or-output-.patch` | Give `g_device` and `g_output` storage that is never destroyed, so their destructors cannot call a Vulkan driver glibc has already unloaded at `exit()` | **Yes** — a plain static-destruction-order bug, not punktfunk-specific |
| `0007-pipewire-never-leave-pw_buffer-user_data-pointing-at.patch` | Associate `pw_buffer->user_data` with its `pipewire_buffer` for every path out of `add_buffer`, clear it in `remove_buffer` (the last point both halves are known), and null-check the consumers — killing the use-after-free that aborted the session on every capture renegotiation | **Yes** — a plain use-after-free in the PipeWire buffer lifecycle |
| `0008-steamcompmgr-honor-GAMESCOPE_NO_FOCUS-never-a-focus-.patch` | Honor `GAMESCOPE_NO_FOCUS` (set by hhd-ui and MangoHud, consumed by nobody): such windows are skipped by both focus-candidate collectors, so a mapped-but-unpainted overlay app can no longer win focus and turn the composite black. Compositing is untouched — only focus SELECTION is barred | **Yes** — the atom's setters already exist in the wild; some compositor has to keep the promise |
| `0009-pipewire-destroy-capture-textures-on-the-compositor-.patch` | Move capture-buffer destruction off the PipeWire thread: `remove_buffer`/stale-push queue the corpse (`bury_buffer`), steamcompmgr reaps on every vblank — including while the stream is paused, which is exactly the linger window. Without it, dropping the last `CVulkanTexture` ref on the PW thread races `vulkan_screenshot` on the same device and SIGSEGVs (NVIDIA `insertBarrier`), so a lingered display is dead and reconnect loses the session. Reported + written by luxus (punktfunk-overlay#9) | **Yes** — the race is upstream's `paint_pipewire` vs `destroy_buffer`; our patches only make the paint path heavier |
### Why the headless patch matters
@@ -68,6 +69,20 @@ variant of the same fault instead. Two traps when triaging it:
lands in a working-looking game mode at the wrong resolution and without any of these patches.
Read the banner in `~/.gamescope-stdout.log`, not the fact that a session exists.
### Why the teardown patch is what makes linger real
Patch 0007 keeps a session alive across renegotiations; patch 0009 keeps it alive across
*disconnects*. When the capture consumer leaves, `stream_handle_remove_buffer` used to destroy
idle buffers on the PipeWire thread — and `~CVulkanTexture` talks to the Vulkan device
(`vkDestroyImage`/`FreeMemory`/dmabuf fds) while steamcompmgr can still be inside
`vulkan_screenshot` on another buffer of the same 4-buffer pool. On NVIDIA that races to a SIGSEGV
in `CVulkanCmdBuffer::insertBarrier`, timed precisely at stream end — so the display the host
keeps lingering for a reconnect is already dead, and the "resumed" session silently becomes a
fresh compositor with the game lost. The journal signature: a linger line, then a coredump, then
`kept display was dead — recreating`. Found, fixed and proven live by luxus
([punktfunk-overlay#9](https://github.com/luxus/punktfunk-overlay/issues/9)) on 4K60 HDR + composited
cursor, the heaviest paint path we ship.
## Why the marker exists
punktfunk decides a session's shape **before** the virtual display exists: the bit depth at
@@ -87,6 +102,7 @@ The number is a **monotonic patch-set revision**, so one probe answers every cap
| `+pfhdr4` | …and `--pipewire-composite-external-overlay` |
| `+pfhdr5` | …and the PipeWire buffer use-after-free is fixed (no new capability) |
| `+pfhdr6` | …and `GAMESCOPE_NO_FOCUS` windows are never focus candidates (no new capability) |
| `+pfhdr7` | …and PipeWire teardown cannot SIGSEGV a lingering compositor (no new capability) |
Bump it whenever a patch adds or changes something the host must know about before it spawns.
@@ -0,0 +1,159 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: luxus <luxus@users.noreply.github.com>
Date: Thu, 13 Aug 2026 23:15:48 +0200
Subject: [PATCH] pipewire: destroy capture textures on the compositor thread
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
When the capture consumer leaves, PipeWire remove_buffer destroys idle
buffers on the PW thread. CVulkanTexture's destructor talks to the Vulkan
device (DestroyImage / FreeMemory / close dmabuf fds). steamcompmgr may
still be inside vulkan_screenshot on another buffer of the same device.
That race SIGSEGVs in CVulkanCmdBuffer::insertBarrier / libnvidia-eglcore
and kills the compositor. A lingering gamescope session then cannot be
reused — the host has to spawn a new one.
Queue those corpses and delete them on the steamcompmgr vblank, including
when the stream is only paused (linger: no consumer, compositor stays up).
The same race exists on the stale-push path (dispatch_nudge destroying a
buffer whose pw_buffer vanished while it was copying), so that call is
buried too. Corpses queued when the compositor exits are reclaimed by the
kernel, same as patch 0006's deliberate leak.
Reported, written and proven live by luxus (punktfunk-overlay#9): four
coredumps on an NVIDIA host, all at stream end / linger, three in
paint_pipewire → vulkan_screenshot → insertBarrier with a concurrent
destroy_buffer → ~CVulkanTexture on the PipeWire thread; after this patch
a disconnect/reconnect reuses the lingered session.
Not addressed here: stream_handle_add_buffer's `error:` path still deletes
on the PW thread. By the later `goto error`s a texture may be attached, so
the same race is reachable in theory — but only when an add FAILS mid-
renegotiation, which none of the field coredumps show. Left as-is to stay
byte-identical with the change that was proven on-glass.
punktfunk: the banner moves to +pfhdr7. No new capability — but a build
whose linger can die of its own capture teardown is indistinguishable from
one that cannot except by this marker, and "reconnect lost my game" is
exactly the field report that needs that read (same rule as pfhdr5/6).
Upstream: yes — the race is upstream's paint_pipewire vs destroy_buffer;
our HDR/cursor patches only make the paint path heavier.
---
src/meson.build | 3 ++-
src/pipewire.cpp | 28 ++++++++++++++++++++++++++--
src/pipewire.hpp | 3 +++
src/steamcompmgr.cpp | 10 ++++++++--
4 files changed, 39 insertions(+), 5 deletions(-)
diff --git a/src/meson.build b/src/meson.build
index acfcaea..fe854af 100644
--- a/src/meson.build
+++ b/src/meson.build
@@ -186,7 +186,8 @@ vcs_tag = run_command(vcs_tag_cmd, check: false).stdout().strip()
# +pfhdr4 — …and `--pipewire-composite-external-overlay`
# +pfhdr5 — …and the PipeWire buffer use-after-free is fixed (no new capability)
# +pfhdr6 — …and GAMESCOPE_NO_FOCUS windows are never focus candidates (no new capability)
-version_tag = vcs_tag + '+pfhdr6' + ' (' + compiler_name + ' ' + compiler_version + ')'
+# +pfhdr7 — …and PipeWire teardown cannot SIGSEGV a lingering compositor (no new capability)
+version_tag = vcs_tag + '+pfhdr7' + ' (' + compiler_name + ' ' + compiler_version + ')'
gamescope_version_conf = configuration_data()
gamescope_version_conf.set('VCS_TAG', version_tag)
diff --git a/src/pipewire.cpp b/src/pipewire.cpp
index c683b3a..3c727e9 100644
--- a/src/pipewire.cpp
+++ b/src/pipewire.cpp
@@ -7,6 +7,7 @@
#include <unistd.h>
#include <atomic>
+#include <mutex>
#include <thread>
#include <vector>
@@ -103,6 +104,29 @@ static void destroy_buffer(struct pipewire_buffer *buffer) {
delete buffer;
}
+// The PipeWire thread must not destroy CVulkanTextures: steamcompmgr may be
+// inside vulkan_screenshot on another buffer of the same device. Queue the
+// corpse and let steamcompmgr delete it on the next vblank.
+static std::mutex s_deadBuffersMutex;
+static std::vector<struct pipewire_buffer *> s_deadBuffers;
+
+static void bury_buffer(struct pipewire_buffer *buffer)
+{
+ std::lock_guard<std::mutex> lock(s_deadBuffersMutex);
+ s_deadBuffers.push_back(buffer);
+}
+
+void pipewire_reap_dead_buffers(void)
+{
+ std::vector<struct pipewire_buffer *> dead;
+ {
+ std::lock_guard<std::mutex> lock(s_deadBuffersMutex);
+ dead.swap(s_deadBuffers);
+ }
+ for (struct pipewire_buffer *buffer : dead)
+ destroy_buffer(buffer);
+}
+
void pipewire_destroy_buffer(struct pipewire_buffer *buffer)
{
destroy_buffer(buffer);
@@ -384,7 +408,7 @@ static void dispatch_nudge(struct pipewire_state *state, int fd)
pwr_log.errorf("pw_stream_queue_buffer failed");
}
} else {
- destroy_buffer(buffer);
+ bury_buffer(buffer);
}
}
}
@@ -694,7 +718,7 @@ static void stream_handle_remove_buffer(void *data, struct pw_buffer *pw_buffer)
buffer->buffer = nullptr;
if (!buffer->copying) {
- destroy_buffer(buffer);
+ bury_buffer(buffer);
}
}
diff --git a/src/pipewire.hpp b/src/pipewire.hpp
index b4d7e29..d0b510c 100644
--- a/src/pipewire.hpp
+++ b/src/pipewire.hpp
@@ -60,5 +60,8 @@ uint32_t get_pipewire_stream_node_id(void);
struct pipewire_buffer *dequeue_pipewire_buffer(void);
bool pipewire_is_streaming();
void pipewire_destroy_buffer(struct pipewire_buffer *buffer);
+// Destroy textures queued by the PipeWire thread. Must run on steamcompmgr —
+// CVulkanTexture's dtor talks to the Vulkan device.
+void pipewire_reap_dead_buffers(void);
void push_pipewire_buffer(struct pipewire_buffer *buffer);
void nudge_pipewire(void);
diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp
index 14596ae..932316f 100644
--- a/src/steamcompmgr.cpp
+++ b/src/steamcompmgr.cpp
@@ -9321,8 +9321,14 @@ steamcompmgr_main(int argc, char **argv)
#if HAVE_PIPEWIRE
// Drive on vblank, not the timer: under VRR the timer starves (page flips re-arm it).
- if ( vblank && pipewire_is_streaming() )
- paint_pipewire();
+ // Reap even when the consumer is gone: linger keeps us alive, and textures
+ // queued by remove_buffer must die on this thread, not PipeWire's.
+ if ( vblank )
+ {
+ pipewire_reap_dead_buffers();
+ if ( pipewire_is_streaming() )
+ paint_pipewire();
+ }
#endif
update_vrr_atoms(root_ctx, false, &flush_root);