Every gamescope session ended in a SIGSEGV at exit — the Vulkan device was being destroyed after the driver had gone #124

Merged
enricobuehler merged 1 commits from worktree-gamescope-exit-segfault into main 2026-08-08 21:13:48 +00:00
Owner

Each gamescope-backed session left a coredump behind. It fires after the compositor has finished its work — Primary child shut down!, then the crash — so the stream itself looked fine and this surfaced only as a steady drip of coredumps and a non-zero exit from the spawn. Five of them landed during one ordinary ten-minute session on the GNOME box.

What it actually is

Static destruction order. 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 -> 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 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()

Reproducible in one line, no client and no GPU workload needed:

gamescope --backend headless -W 1280 -H 720 -r 60 --xwayland-count 1 -- true    # exit 139, every time

The fix

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. 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 CNoDestroy<T> helper rather than a one-off.

Why +pfhdrN deliberately does not move

The marker is a capability tier the host probes via gamescope_patch_level() before it spawns. This patch adds no capability, so bumping it would advertise a tier that does not exist and strand hosts that gate on it. Per the PKGBUILD's own rule this ships as a pkgrel bump instead (1 → 2), and the README now spells that split out so the next patch does not have to rediscover it.

No packaging file needed editing otherwise: the PKGBUILD, the RPM spec and packaging/nix/gamescope.nix all glob patches/*.patch rather than enumerating them.

Verification

On an NVIDIA host, all six patches git am-ing onto the pinned upstream commit and then a release build — the shipped configuration, not the debug one used to find this:

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

Notes for whoever touches this next

Two things cost real time here and are worth knowing:

  • coredumpctl's inline backtrace is not trustworthy for a stripped binary. For this same crash it rendered four frames inside libnvidia-eglcore with a libstdc++/clone3 tail, which reads exactly like a worker thread crashing in EGL — a theory I pursued and which was wrong. gdb on the same core against a symbolised build showed the truth. Arch publishes no gamescope debuginfo (404 on debuginfod), so symbolising means building our own with --buildtype=debugoptimized.
  • --xwayland-count 0 is an unrelated bug and is left alone here: it dies far earlier, in main() at wlserver.cpp:3215, dereferencing a null gamescope_xwayland_server_t. It never reaches teardown. Punktfunk always spawns with --xwayland-count 1, so that path is never taken — but it briefly made the fix look ineffective when the real configuration was already clean.

Not filed upstream, per instruction, though the bug is not punktfunk-specific and the patch would apply as-is.

Each gamescope-backed session left a coredump behind. It fires *after* the compositor has finished its work — `Primary child shut down!`, then the crash — so the stream itself looked fine and this surfaced only as a steady drip of coredumps and a non-zero exit from the spawn. Five of them landed during one ordinary ten-minute session on the GNOME box. ## What it actually is Static destruction order. 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 -> 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 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() ``` Reproducible in one line, no client and no GPU workload needed: ``` gamescope --backend headless -W 1280 -H 720 -r 60 --xwayland-count 1 -- true # exit 139, every time ``` ## The fix 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. `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 `CNoDestroy<T>` helper rather than a one-off. ## Why `+pfhdrN` deliberately does not move The marker is a capability tier the host probes via `gamescope_patch_level()` *before* it spawns. This patch adds no capability, so bumping it would advertise a tier that does not exist and strand hosts that gate on it. Per the PKGBUILD's own rule this ships as a `pkgrel` bump instead (1 → 2), and the README now spells that split out so the next patch does not have to rediscover it. No packaging file needed editing otherwise: the PKGBUILD, the RPM spec and `packaging/nix/gamescope.nix` all glob `patches/*.patch` rather than enumerating them. ## Verification On an NVIDIA host, all six patches `git am`-ing onto the pinned upstream commit and then a **release** build — the shipped configuration, not the debug one used to find this: | | | |---|---| | 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 | ## Notes for whoever touches this next Two things cost real time here and are worth knowing: - **`coredumpctl`'s inline backtrace is not trustworthy for a stripped binary.** For this same crash it rendered four frames inside `libnvidia-eglcore` with a `libstdc++`/`clone3` tail, which reads exactly like a worker thread crashing in EGL — a theory I pursued and which was wrong. `gdb` on the same core against a symbolised build showed the truth. Arch publishes no gamescope debuginfo (404 on debuginfod), so symbolising means building our own with `--buildtype=debugoptimized`. - **`--xwayland-count 0` is an unrelated bug** and is left alone here: it dies far earlier, in `main()` at `wlserver.cpp:3215`, dereferencing a null `gamescope_xwayland_server_t`. It never reaches teardown. Punktfunk always spawns with `--xwayland-count 1`, so that path is never taken — but it briefly made the fix look ineffective when the real configuration was already clean. Not filed upstream, per instruction, though the bug is not punktfunk-specific and the patch would apply as-is.
enricobuehler added 1 commit 2026-08-08 18:59:30 +00:00
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
9e7713eecf
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.
enricobuehler merged commit 8fe834c89b into main 2026-08-08 21:13:48 +00:00
enricobuehler deleted branch worktree-gamescope-exit-segfault 2026-08-08 21:13:52 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#124