fix(gamescope): every session ended in a SIGSEGV at exit
ci / bun-nix (pull_request) Successful in 27s
ci / web (pull_request) Successful in 1m1s
ci / docs-site (pull_request) Successful in 1m11s
ci / rust-arm64 (pull_request) Successful in 2m30s
ci / rust (pull_request) Successful in 4m47s

Each gamescope-backed session left a coredump behind. It happened after the
compositor had finished its work — "Primary child shut down!", then the crash —
so the stream itself looked fine and it surfaced only as a steady drip of
coredumps and a non-zero exit from the spawn.

It is a static-destruction-order bug, not a race and not anything gamescope does
wrong at runtime. `g_device` (CVulkanDevice) and `g_output` (VulkanOutput_t) were
plain globals, so glibc ran their destructors from `__run_exit_handlers` once
main() returned. Those destructors call back into the driver —
`~CVulkanCmdBuffer` -> `vk.FreeCommandBuffers`, `~CVulkanTexture` -> `vk.Destroy*`
— but the Vulkan ICD has already been torn down and unloaded by then, so each
call jumps through a function pointer into an unmapped page. The faulting address
equalling the instruction pointer is the signature:

  #0  0x00007fe8fd1d1070 in ?? ()
  #1  CVulkanCmdBuffer::~CVulkanCmdBuffer   at rendervulkan.cpp:1543
  #9  std::vector<unique_ptr<CVulkanCmdBuffer>>::~vector  (g_device+1792)
  #10 CVulkanDevice::~CVulkanDevice         at rendervulkan.hpp:768
  #11 __run_exit_handlers / exit()

Patch 0006 gives both globals storage that is constructed exactly as before but
never destroyed; a union member is destroyed only if the union's destructor says
so, and ours deliberately does not. Nothing needs freeing there — the process is
exiting and the kernel reclaims the device, its command buffers and every GPU
allocation. Both objects are needed: pinning only the device relocated the fault
into ~VulkanOutput_t.

The `.pfhdrN` level deliberately stays at 4. It is a capability tier the host
probes before it spawns, and this patch adds no capability — bumping it would
advertise a tier that does not exist. Per the PKGBUILD's own rule this ships as a
`pkgrel` bump instead.

Verified on an NVIDIA box, all six patches `git am`-ing onto the pinned upstream
commit and then a RELEASE build (the shipped configuration):

  version banner   3.16.25-7-gea635c1+pfhdr4   (marker intact)
  patched, real spawn shape (2752x2064@120 --steam --xwayland-count 1)
                   6/6 exit 0
  distro control, same shape
                   SIGSEGV

Not filed upstream, though it is not punktfunk-specific and would apply as-is.

Unrelated and left alone: `--xwayland-count 0` dies much earlier, in main() at
wlserver.cpp:3215, dereferencing a null `gamescope_xwayland_server_t`. Punktfunk
always spawns with `--xwayland-count 1`, so that path is never taken here.
This commit is contained in:
2026-08-08 19:21:39 +02:00
parent 0cd946acb5
commit 9e7713eecf
3 changed files with 135 additions and 1 deletions
+3 -1
View File
@@ -20,7 +20,9 @@ pkgname=punktfunk-gamescope
_gsver=3.16.25
_gsrev=8c676c399c761e4540587f61004c957993d12fea
pkgver="${_gsver}.pfhdr4"
pkgrel=1
# 2: patch 0006 (never destroy the Vulkan device/output at exit). No capability moved, so the
# `.pfhdrN` level deliberately stays put — see README.md.
pkgrel=2
pkgdesc="gamescope with 10-bit BT.2020/PQ PipeWire capture, for punktfunk HDR streaming"
arch=('x86_64' 'aarch64')
url="https://git.unom.io/unom/punktfunk"
+7
View File
@@ -16,6 +16,7 @@ The patches here add the missing half, and nothing else. See
| `0003-headless-advertise-the-virtual-display-s-mode-and-re.patch` | Give `CHeadlessConnector` a real `GetModes()` + `GetValidDynamicRefreshRates()` from the resolved `-W`/`-H`/`-r`, report `GAMESCOPE_SCREEN_TYPE_EXTERNAL` so `update_mode_atoms` publishes the list, and add `--custom-refresh-rates` | **Yes** — a headless session that cannot report its own mode is a plain bug |
| `0004-pipewire-optionally-composite-the-external-overlay-i.patch` | `--pipewire-composite-external-overlay` (off by default): paint the external overlay layer (mangoapp — the fps/stats readout) into the capture stream | **Yes** — same shape as the cursor patch, same argument |
| `0005-punktfunk-stamp-the-version-banner-with-pfhdrN.patch` | Append `+pfhdr<N>` to the `--version` banner | **No** — ours only, retired when the functional patches above land upstream |
| `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 |
### Why the headless patch matters
@@ -59,6 +60,12 @@ The number is a **monotonic patch-set revision**, so one probe answers every cap
Bump it whenever a patch adds or changes something the host must know about before it spawns.
A patch that only fixes a crash does **not** bump it: `0006` (the exit-time Vulkan teardown fix)
changes nothing the host probes for, so the level stays `+pfhdr4` and the rebuild ships as a
`pkgrel` bump instead — exactly the split the PKGBUILD's own comment describes. Bumping the level
for a bugfix would be worse than useless: it would advertise a capability tier that does not exist
and strand hosts that gate on it.
⚠️ The two indirect spawn modes (the `GAMESCOPE_BIN` wrapper for gamescope-session-plus, and the
SteamOS PATH shim) pass these flags through `PF_HDR_ARGS`, so they share one dependency: if the
session ignores `GAMESCOPE_BIN`/`PATH` and execs the distro's gamescope, it gets neither the HDR
@@ -0,0 +1,125 @@
From 509fb928c7dc3307372629ca692f4c895c4fe984 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Enrico=20B=C3=BChler?= <enrico.buehler@unom.io>
Date: Sat, 8 Aug 2026 19:17:25 +0200
Subject: [PATCH] punktfunk: never destroy the Vulkan device or output at exit
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Every gamescope session punktfunk spawns ended in SIGSEGV. It happened after
the compositor had already done its work — "Primary child shut down!", then a
coredump — so the stream itself looked fine and the crash only showed up as a
steady drip of coredumps and a non-zero exit from the spawn.
The cause is static destruction order, not anything gamescope does wrong at
runtime. `g_device` (CVulkanDevice) and `g_output` (VulkanOutput_t) were plain
globals, so glibc ran their destructors from `__run_exit_handlers` once main()
returned. Those destructors call back into the driver:
~CVulkanCmdBuffer -> m_device->vk.FreeCommandBuffers(...)
~CVulkanTexture -> vk.Destroy*(...)
but the Vulkan ICD has already been torn down and unloaded by that point, so
each call jumps through a function pointer into an unmapped page. The faulting
address equals the instruction pointer, which is the signature of exactly that:
#0 0x00007fe8fd1d1070 in ?? ()
#1 CVulkanCmdBuffer::~CVulkanCmdBuffer at rendervulkan.cpp:1543
#9 std::vector<unique_ptr<CVulkanCmdBuffer>>::~vector (g_device+1792)
#10 CVulkanDevice::~CVulkanDevice at rendervulkan.hpp:768
#11 __run_exit_handlers / exit()
On NVIDIA it is 100% reproducible:
`gamescope --backend headless -W 1280 -H 720 -r 60 --xwayland-count 1 -- true`
exits 139 every time, and cleanly with this patch (5/5, plus 2/2 at the real
session's 2752x2064@120 --steam).
Nothing needs freeing at that point. The process is exiting; the kernel
reclaims the device, its command buffers and every GPU allocation. So give both
objects storage that is constructed exactly as before but never destroyed — a
union member is destroyed only if the union's destructor says so, and ours
deliberately does not. `g_device` and `g_output` keep their names and types
(now references bound at constant-initialisation time), so no use site changes.
Both are needed: pinning only the device relocated the fault into
~VulkanOutput_t, which is why this is a shared helper and not a one-off.
---
src/rendervulkan.cpp | 35 +++++++++++++++++++++++++++++++++--
src/rendervulkan.hpp | 4 ++--
2 files changed, 35 insertions(+), 4 deletions(-)
diff --git a/src/rendervulkan.cpp b/src/rendervulkan.cpp
index 5c2dd11..8cd5ca2 100644
--- a/src/rendervulkan.cpp
+++ b/src/rendervulkan.cpp
@@ -118,7 +118,37 @@ static VkResult vulkan_load_module()
return s_result;
}
-VulkanOutput_t g_output;
+// punktfunk: globals that own GPU objects must OUTLIVE static destruction.
+//
+// gamescope kept its Vulkan device and output as plain globals, so glibc ran their destructors
+// from `__run_exit_handlers` after main() returned. Those destructors call back into the driver
+// (~CVulkanCmdBuffer -> vk.FreeCommandBuffers, ~CVulkanTexture -> vk.Destroy*), but by then the
+// Vulkan ICD has already been torn down and unloaded, so the call jumps through a function
+// pointer into an unmapped page: SIGSEGV at exactly the address it tried to execute. On NVIDIA it
+// is 100% reproducible -- `gamescope --backend headless ... -- true` dies with exit 139 EVERY
+// time -- so every punktfunk gamescope session ended in a coredump.
+//
+// Nothing needs freeing at that point: the process is exiting and the kernel reclaims the device,
+// its command buffers and every GPU allocation. So give these objects storage that is constructed
+// exactly as before but NEVER destroyed. A union member is only destroyed if the union says so,
+// and ours deliberately does not.
+//
+// Fixing only one of them just moves the crash to the next global (verified: pinning the device
+// relocated the fault into ~VulkanOutput_t), which is why this is a shared helper rather than a
+// one-off.
+namespace
+{
+ template <typename T>
+ union CNoDestroy
+ {
+ T value;
+ CNoDestroy() : value() {}
+ ~CNoDestroy() {} // deliberately does NOT destroy `value`
+ };
+}
+
+namespace { CNoDestroy<VulkanOutput_t> g_outputHolder; }
+VulkanOutput_t &g_output = g_outputHolder.value;
uint32_t g_uCompositeDebug = 0u;
gamescope::ConVar<uint32_t> cv_composite_debug{ "composite_debug", 0, "Debug composition flags" };
@@ -1943,7 +1973,8 @@ void CVulkanCmdBuffer::insertBarrier(bool flush)
0, 0, nullptr, 0, nullptr, barriers.size(), barriers.data());
}
-CVulkanDevice g_device;
+namespace { CNoDestroy<CVulkanDevice> g_deviceHolder; }
+CVulkanDevice &g_device = g_deviceHolder.value;
static bool allDMABUFsEqual( wlr_dmabuf_attributes *pDMA )
{
diff --git a/src/rendervulkan.hpp b/src/rendervulkan.hpp
index b6749d4..a9335c4 100644
--- a/src/rendervulkan.hpp
+++ b/src/rendervulkan.hpp
@@ -564,7 +564,7 @@ enum ShaderType {
SHADER_TYPE_COUNT
};
-extern VulkanOutput_t g_output;
+extern VulkanOutput_t &g_output;
struct SamplerState
{
@@ -1007,4 +1007,4 @@ void vulkan_wait_idle();
// Whether the driver implements VK_EXT_physical_device_drm
bool vulkan_has_drm_props();
-extern CVulkanDevice g_device;
+extern CVulkanDevice &g_device;
--
2.55.0