Skip to content

fix(linux): stop a new stream from crashing streams already encoding with CUDA - #6014

Merged
ReenigneArcher merged 7 commits into
LizardByte:masterfrom
kriansa:fix/cuda-init-race
Nov 9, 2026
Merged

ReenigneArcher merged 7 commits into
LizardByte:masterfrom
kriansa:fix/cuda-init-race

Conversation

@kriansa

@kriansa kriansa commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Description

Sunshine randomly crashes when a second client connects while another one is already streaming. It doesn't happen on every connect (about one in three in my case), but when it does, every client's stream drops at once. Seen on Linux with NVENC and wlr capture (headless labwc, NVIDIA 615.x). The only trace is in the kernel log:

kernel: session::video[...]: segfault at 0 ip 0000000000000000 sp ... error 14

The cause is that cuda::init() reloads the shared CUDA function table (cdf) on every call, and every new stream calls it when it creates its encode device. cuda_load_functions() frees the current table, installs a zeroed one, and only then opens libcuda.so.1 and fills the entries in. A stream that is already encoding calls through cdf on every frame. If it does so inside that window, it calls a null function pointer and the whole process dies.

Backtrace from the core dump (v2026.914.233613; master has the same init()):

Thread 1 (the stream already running)
#0  0x0000000000000000 in ?? ()
#1  cuda::gl_cuda_vram_t::convert(platf::img_t&) ()      <- call *0x278(%rax), rax = freshly zeroed table
#2  stream::videoThread(stream::session_t*) ()

Thread 15 (the stream that just started)
#1  _dl_lookup_map (name="libcuda.so.1")
#7  dlopen
#8  cuda::init() ()
#9  cuda::make_avcodec_gl_encode_device(int, int, int, int) ()
#10 wl::wlr_vram_t::make_avcodec_encode_device(platf::pix_fmt_e) ()
#11 video::make_encode_device(...) ()
#12 stream::videoThread(stream::session_t*) ()

On current master (after #6010) the running stream crashes the same way, now in the GL→CUDA handoff:

#0  0x0000000000000000 in ?? ()
#1  cuda::make_gl_cuda_handoff_ops(cuda::gl_cuda_frame_copy_t const&, std::function<void ()>)::{lambda()#1}
#2  cuda::gl_cuda_handoff(bool, int, cuda::gl_cuda_handoff_ops_t const&) ()
#3  cuda::gl_cuda_vram_t::convert(platf::img_t&) ()

This change loads the table once and keeps it for the life of the process. Later calls only run cuInit(0) again, so a first load that failed is still retried. A mutex stops streams that start at the same time from loading it concurrently. To test this without a GPU, cuda::init() now hands the table and the loader to an init(table, load) overload, the same way device_pci_bus_id() takes its driver operations. The overload reports a failing cuInit through the table it was given, using the table-aware check() from #6010.

Screenshot

Issues Fixed or Closed

Roadmap Issues

Testing

I first made the crash reproducible on demand, so it happened on every attempt instead of about one connect in three. Then I applied the fix, rebuilt Sunshine, and ran it on the same machine. The end-to-end tests with the fix were clean: no crashes, and both streams kept running. The crash happened every time before the fix and never after it, which confirms the fix resolves it. The details are below.

Regression tests in tests/unit/platform/linux/test_cuda.cpp. They use a fake loader, so they need no GPU and run in CI:

  • LoadsTheFunctionTableOnlyOnce: a second init() must not load the table again.
  • ReportsACuInitFailureThroughTheTableAndRetriesItWithTheSameTable: a failing cuInit is reported through the fake error callbacks of the table given, and the retry succeeds with the same table, one load and two cuInit calls.
  • CallersStartingTogetherLoadTheTableOnce: 8 callers start together against a slow fake loader. All succeed, the table is loaded once, and cuInit runs once per caller. The first load waits until a second caller reaches the loader, or 200 ms at most, so the test costs about 200 ms when it passes.
  • RetriesLoadingAfterAFailure: a load that failed is retried on the next call.

The first three fail on a build with these tests but without the fix, and pass with it, in 20 out of 20 repeats each. The fourth passes on both. As a check of the third test, removing only the mutex (keeping the load-once check) made it fail in 50 out of 50 repeats while the other three still passed.

Full test_sunshine suite, in a Fedora 44 container: 1019 passed and 3 failed (the tests above) without the fix, and 1022 passed with it. 22 tests were skipped in both runs.

Against the real driver (Fedora 44, RTX 3050, driver 615.71.10): a manual check, not part of this PR, intercepted dlopen("libcuda.so.1") during a second init(). Without the fix, the entry every frame conversion calls first (cuGraphicsMapResources) was null in 30 out of 30 runs.

End to end: headless labwc, wlr capture, NVENC, with one client streaming continuously. A preloaded shim delayed dlopen("libcuda.so.1") by 100 ms to widen the window:

  • without the fix: a second client connected 3 times, and all 3 crashed Sunshine with the segfault above
  • with this PR: a second client connected 7 times, with no crashes, and both streams kept running

Repeated after merging current master (4cce5bf6):

  • without the fix: 3 out of 3 connects crashed, with the backtrace above
  • with the fix: 6 out of 6 connects were clean, and Sunshine kept the same process throughout

Built with BUILD_WERROR=ON (gcc 15, CUDA 13.1.1). clang-format 23.1.0 and git diff --check report nothing, and the docs build passes. Not run: CI, Windows and macOS (the change is Linux-only).

Type of Change

  • feat: New feature (non-breaking change which adds functionality)
  • fix: Bug fix (non-breaking change which fixes an issue)
  • docs: Documentation only changes
  • style: Changes that do not affect the meaning of the code (white-space, formatting, missing semicolons, etc.)
  • refactor: Code change that neither fixes a bug nor adds a feature
  • perf: Code change that improves performance
  • test: Adding missing tests or correcting existing tests
  • build: Changes that affect the build system or external dependencies
  • ci: Changes to CI configuration files and scripts
  • chore: Other changes that don't modify src or test files
  • revert: Reverts a previous commit
  • BREAKING CHANGE: Introduces a breaking change (can be combined with any type above)

Checklist

  • Code follows the style guidelines of this project
  • Code has been self-reviewed
  • Code has been commented, particularly in hard-to-understand areas
  • Code docstring/documentation-blocks for new or existing methods/components have been added or updated
  • Tests have been added or updated for new or modified functionality, including regression tests for bug fixes

AI Usage

See our AI usage policy.

  • AI tools were used while preparing this PR

Claude helped read the core dump, write the test and the fix, and run the verification described above.

@kriansa kriansa changed the title Fix/cuda init race fix(linux): stop a new stream from crashing streams already encoding with CUDA Oct 30, 2026
Every stream that starts calls cuda::init(), which reloads the table of
CUDA driver functions that every encode device shares. The loader empties
the table before filling it in again, so a stream already encoding through
it can call a null function pointer and take the whole process down. On
Linux with NVENC this crashes Sunshine at random when a second client
connects while another one is streaming.

cuda::init() now hands the table and the loader to an overload, the way
device_pci_bus_id() takes its driver operations, so tests can use a fake
loader and need no GPU. One test checks that a second init() does not load
the table again; it fails until the next commit. The other checks that a
load that failed is retried on the next call.
cuda::init() reloaded the shared CUDA function table on every call, and
every new stream calls it. Reloading empties the table before filling it
again, so a stream already encoding through it could call a null function
pointer, crashing Sunshine when a second client connected.

Load the table only when it is not loaded yet, and keep it for the life of
the process. Later calls only run cuInit again, so a first load that failed
is still retried. A mutex stops streams that start at the same time from
loading it concurrently.
@kriansa
kriansa force-pushed the fix/cuda-init-race branch from ffe05c7 to 792ff31 Compare October 30, 2026 03:42

@ReenigneArcher ReenigneArcher left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The load-once change addresses a race that is still present on current master (4cce5bf6). Requesting changes because the new injected initialization path can dereference the unrelated global table when CUDA initialization fails.

Please extend tests/unit/platform/linux/test_cuda.cpp to cover both remaining behaviors:

  • Make the injected cuInit fail once, supply fake error-name/error-string callbacks, and assert failure is reported through those callbacks. Retry successfully and assert the same table is retained, the loader ran once, and cuInit ran twice.
  • Start multiple callers together with a gated/delayed fake loader and thread-safe counters; assert every caller succeeds, the table is loaded once, and cuInit runs once per caller. The two existing sequential cases do not detect removal of the mutex.

Run these in a CUDA-enabled Linux build with cmake-build-debug/tests/test_sunshine --gtest_filter='CudaInitTest.*' (adjust the build-directory name).

GitHub reports confirmed merge conflicts, also reproduced locally in src/platform/linux/cuda.cpp and tests/unit/platform/linux/test_cuda.cpp. Please rebase onto master and resolve them before merging, retaining the table-aware error-checking helper introduced by #6010.

Validation: git diff --check and clang-format 23.1.0 dry-run checks passed for the changed files. A macOS harness using the exact PR initialization/check bodies and ASan/UBSan passed sequential load, loader retry, and 25 rounds of 16 concurrent callers; injected initialization failure triggered a null-pointer diagnostic. The table-aware correction passed all four cases. These are isolated checks, not a run of the repository's Linux test executable or GPU streaming validation.

Hosted verification remains limited by workflows awaiting maintainer approval. I inspected the failed GH-Pages documentation build: it timed out waiting for the absent Jekyll workflow, rather than reporting a source/documentation error.

Review completed by Codex | Model: gpt-6.1-sol | Thinking: xhigh

Comment thread src/platform/linux/cuda.cpp Resolved
Comment thread tests/unit/platform/linux/test_cuda.cpp Resolved
# Conflicts:
#	src/platform/linux/cuda.cpp
#	tests/unit/platform/linux/test_cuda.cpp
init(table, load) checked the result of cuInit with CU_CHECK, which looks
up the error's name and description through the global table rather than
the one passed in. In production they are the same table, but with any
other table a failing cuInit dereferences the global one, which may not be
loaded at all, instead of returning -1. Use the table-aware check() from
LizardByte#6010 instead.
Two cases the existing init() tests miss, both raised in review.

A failing cuInit must be reported through the error callbacks of the table
init() was given, and the retry must reuse that table: one load, two cuInit
calls.

Callers starting together must load the table once between them. Eight of
them start at once against a slow fake loader, whose first load waits until
a second caller reaches the loader, or 200 ms at most. The sequential tests
still pass without the mutex; this one fails every time. The loader hands
out a shared table and frees nothing, so a regression shows up as a wrong
count rather than a double-free crash.

The fakes move into one shared state with atomic counters, since the
loader is a plain function pointer and the callers run on several threads.
SonarCloud flags function pointers used as parameters (cpp:S5205), which
failed the quality gate on init(table, load). Take the loader as a
std::function by const reference instead, as the other injected
operations in cuda.h already do. Callers don't change: plain functions and
lambdas convert to it.
@kriansa

kriansa commented Nov 8, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review. I merged master (4cce5bf6) into the branch to resolve the conflicts, keeping the existing history, and fixed the requested comments. The quality gate passes again.

About the tests:

  • ReportsACuInitFailureThroughTheTableAndRetriesItWithTheSameTable fails cuInit once, then retries it.
  • CallersStartingTogetherLoadTheTableOnce starts 8 callers together against a slow fake loader. The first load waits until a second caller reaches the loader, or 200 ms at most. The loader hands out a shared table and frees nothing, so a regression shows up as a wrong count rather than a double-free crash. The test takes about 200 ms when it passes.

Results (test_sunshine --gtest_filter='CudaInitTest.*', CUDA-enabled Linux build, 20 repeats):

  • With these tests but without the fix, the load-once, cuInit-retry and concurrent tests fail in every repeat.
  • With the fix, all four pass in every repeat.
  • Removing only the mutex (keeping the load-once check) makes the concurrent test fail in 50 out of 50 repeats, while the other three still pass.

Full suite: 1022 passed, 0 failed, 22 skipped.

End to end on current master, on the real GPU:

clang-format 23.1.0 and git diff --check report nothing, and the docs build passes. I've updated the PR description with these results.

@sonarqubecloud

sonarqubecloud Bot commented Nov 8, 2026

Copy link
Copy Markdown

@codecov

codecov Bot commented Nov 8, 2026 •

Copy link
Copy Markdown

Bundle Report

Changes will decrease total bundle size by 1.48kB (-0.04%) ⬇️. This is within the configured threshold ✅

Detailed changes
Bundle name Size Change
sunshine-esm 997.33kB -1.48kB (-0.15%) ⬇️

Affected Assets, Files, and Routes:

view changes for bundle: sunshine-esm

Assets Changed:

Asset Name Size Change Total Size Change (%)
images/logo-*.svg (Deleted) -1.48kB 0 bytes -100.0% 🗑️

@codecov

codecov Bot commented Nov 8, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 76.92308% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 48.49%. Comparing base (7216edb) to head (55ef868).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/platform/linux/cuda.cpp 76.92% 0 Missing and 3 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##           master    #6014      +/-   ##
==========================================
- Coverage   49.34%   48.49%   -0.85%     
==========================================
  Files         143      141       -2     
  Lines       31292    30963     -329     
  Branches    13350    13061     -289     
==========================================
- Hits        15441    15017     -424     
+ Misses      14538    13429    -1109     
- Partials     1313     2517    +1204     
Flag Coverage Δ
Alpine-aarch64 33.06% <ø> (+<0.01%) ⬆️
Alpine-x86_64 33.19% <ø> (ø)
Archlinux 34.26% <76.92%> (+0.03%) ⬆️
FreeBSD-amd64 33.84% <ø> (ø)
Homebrew-macos-15 47.91% <ø> (ø)
Homebrew-macos-26 48.30% <ø> (-0.02%) ⬇️
Homebrew-ubuntu-24.04 36.29% <75.00%> (+0.02%) ⬆️
Linux-AppImage-aarch64 38.82% <75.00%> (+0.02%) ⬆️
Linux-AppImage-x86_64 39.07% <75.00%> (+0.02%) ⬆️
PowerShell 100.00% <ø> (ø)
Web-UI 56.65% <ø> (ø)
Windows-AMD64 ?
Windows-ARM64 33.86% <ø> (+<0.01%) ⬆️
macOS-arm64 40.48% <ø> (ø)
macOS-x86_64 40.03% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/platform/linux/cuda.h 95.19% <ø> (ø)
src/platform/linux/cuda.cpp 35.66% <76.92%> (+0.67%) ⬆️

... and 59 files with indirect coverage changes


Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 7216edb...55ef868. Read the comment docs.

@github-actions

github-actions Bot commented Nov 8, 2026

Copy link
Copy Markdown
Last Updated 2026-11-08 23:34:48 UTC
Source Run CI Run #6638
Commit 55ef86889c55d9feebce267efda919778d9474ce

Screenshot Comparison

PR #6014 screenshots vs screenshots baseline.

Matrix: AppImage-aarch64

Image Baseline PR
sunshine_tray_initial.png
sunshine_tray_menu.png
sunshine_tray_pairing_request.png
sunshine_tray_paused.png
sunshine_tray_stopped.png
sunshine_tray_streaming.png
sunshine_tray_tooltip.png

Matrix: AppImage-x86_64

Image Baseline PR
sunshine_tray_initial.png
sunshine_tray_menu.png
sunshine_tray_pairing_request.png
sunshine_tray_paused.png
sunshine_tray_stopped.png
sunshine_tray_streaming.png
sunshine_tray_tooltip.png

Matrix: Windows-AMD64

Image Baseline PR
sunshine_tray_initial.png
sunshine_tray_menu.png
sunshine_tray_pairing_request.png
sunshine_tray_paused.png
sunshine_tray_stopped.png
sunshine_tray_streaming.png
sunshine_tray_tooltip.png

Matrix: Windows-ARM64

Image Baseline PR
sunshine_tray_initial.png
sunshine_tray_menu.png
sunshine_tray_pairing_request.png
sunshine_tray_paused.png
sunshine_tray_stopped.png
sunshine_tray_streaming.png
sunshine_tray_tooltip.png

Matrix: macOS-arm64

Image Baseline PR
sunshine_tray_initial.png
sunshine_tray_menu.png
sunshine_tray_pairing_request.png
sunshine_tray_paused.png
sunshine_tray_stopped.png
sunshine_tray_streaming.png
sunshine_tray_tooltip.png

Matrix: macOS-x86_64

Image Baseline PR
sunshine_tray_initial.png
sunshine_tray_menu.png
sunshine_tray_pairing_request.png
sunshine_tray_paused.png
sunshine_tray_stopped.png
sunshine_tray_streaming.png
sunshine_tray_tooltip.png

@ReenigneArcher
ReenigneArcher merged commit 7810707 into LizardByte:master Nov 9, 2026
85 of 87 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants